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..81128f172a --- /dev/null +++ b/.github/actions/export-env-vars/action.yml @@ -0,0 +1,49 @@ +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"] + # Single source of truth shared with the Makefile and build/tools/templates.go. + workspace = os.environ.get("GITHUB_WORKSPACE", ".") + prefixes_path = os.path.join(workspace, "build", "bb-build-var-prefixes.txt") + with open(prefixes_path, encoding="utf-8") as prefixes_file: + alias_prefixes = tuple( + line.strip() + for line in prefixes_file + if line.strip() and not line.strip().startswith("#") + ) + + 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/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..0dfe9a4a88 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + # Keep third-party GitHub Actions (pinned to commit SHAs in the workflows) + # bumped deliberately via PRs instead of silently tracking a moving tag. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "ci" + + # Python tooling used by the CI workflows (.github/requirements.txt). + - package-ecosystem: "pip" + directory: "/.github" + schedule: + interval: "weekly" + commit-message: + prefix: "ci" diff --git a/.github/requirements.txt b/.github/requirements.txt new file mode 100644 index 0000000000..f62ce0c56d --- /dev/null +++ b/.github/requirements.txt @@ -0,0 +1 @@ +PyYAML==6.0.3 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..8539cf7101 --- /dev/null +++ b/.github/scripts/deploy_plan.py @@ -0,0 +1,95 @@ +#!/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: + # Preserve the network suffix (e.g. "_sepolia", "_nile", "4") so distinct + # testnets of the same coin get distinct names instead of all collapsing to + # "=test". Keeps the mapping injective. Must stay in sync with + # getMatchableName() in tests/integration.go. + return coin[:idx] + "=test" + coin[idx + len(marker):] + return coin + "=main" + + +def build_connectivity_regex(names) -> str: + # Anchor each name so e.g. "bitcoin=test" cannot substring-match the + # "bitcoin=test4" subtest. Safe because matchable_name() is injective, so + # Go never appends a "#NN" disambiguator that an anchor would exclude. + if not names: + # Fail closed: an empty alternation "()" matches the empty string, so + # `go test -run` would select EVERY connectivity subtest — the opposite + # of "no coins". Callers must filter to a non-empty set first. + raise ValueError("build_connectivity_regex requires at least one name") + escaped = ["^" + re.escape(name) + "$" for name in names] + return "TestIntegration/(" + "|".join(escaped) + ")/connectivity" + + +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)) + connectivity_regex = build_connectivity_regex(unique_names) + + 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"connectivity_regex={connectivity_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("Connectivity regex: " + connectivity_regex) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/deploy_plan_test.py b/.github/scripts/deploy_plan_test.py new file mode 100644 index 0000000000..84cde1660d --- /dev/null +++ b/.github/scripts/deploy_plan_test.py @@ -0,0 +1,83 @@ +import json +import re +import unittest +from pathlib import Path + +from deploy_plan import build_connectivity_regex, matchable_name + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +class MatchableNameTest(unittest.TestCase): + def test_known_mappings(self) -> None: + cases = { + "ethereum": "ethereum=main", + "tron": "tron=main", + "bitcoin_regtest": "bitcoin_regtest=main", + "bitcoin_testnet": "bitcoin=test", + "bitcoin_testnet4": "bitcoin=test4", + "ethereum_testnet_sepolia": "ethereum=test_sepolia", + "ethereum_testnet_hoodi": "ethereum=test_hoodi", + "tron_testnet_nile": "tron=test_nile", + } + for coin, expected in cases.items(): + self.assertEqual(matchable_name(coin), expected, coin) + + def test_sibling_testnets_do_not_collide(self) -> None: + # The bug this guards: every "_testnet*" used to collapse to + # "=test", so the deploy connectivity regex for one testnet also + # selected its siblings. + self.assertNotEqual( + matchable_name("ethereum_testnet_sepolia"), + matchable_name("ethereum_testnet_hoodi"), + ) + self.assertNotEqual( + matchable_name("bitcoin_testnet"), + matchable_name("bitcoin_testnet4"), + ) + + def test_injective_over_real_tests_json(self) -> None: + # Regression guard: no two tests.json keys may share a matchable name, + # otherwise the deploy connectivity gate would run sibling coins too. + keys = json.loads((REPO_ROOT / "tests" / "tests.json").read_text("utf-8")).keys() + seen: dict[str, str] = {} + for key in keys: + name = matchable_name(key) + self.assertNotIn( + name, seen, f"{key} and {seen.get(name)} both map to {name}" + ) + seen[name] = key + + +class ConnectivityRegexTest(unittest.TestCase): + def _element_pattern(self, regex: str) -> str: + # The middle "/(...)/" group is what `go test -run` matches against a + # single subtest-name path element. + match = re.fullmatch(r"TestIntegration/\((.*)\)/connectivity", regex) + self.assertIsNotNone(match, regex) + return match.group(1) + + def test_anchored_names_do_not_substring_match_siblings(self) -> None: + pattern = self._element_pattern(build_connectivity_regex(["bitcoin=test"])) + # Unanchored, "bitcoin=test" would also match "bitcoin=test4"; anchored it + # must not. + self.assertTrue(re.search(pattern, "bitcoin=test")) + self.assertFalse(re.search(pattern, "bitcoin=test4")) + + def test_multiple_names_are_alternated(self) -> None: + pattern = self._element_pattern( + build_connectivity_regex(["ethereum=test_hoodi", "ethereum=test_sepolia"]) + ) + self.assertTrue(re.search(pattern, "ethereum=test_hoodi")) + self.assertTrue(re.search(pattern, "ethereum=test_sepolia")) + self.assertFalse(re.search(pattern, "ethereum=main")) + + def test_empty_names_fails_closed(self) -> None: + # An empty alternation "()" would match every connectivity subtest, the + # opposite of selecting no coins; the function must refuse instead. + with self.assertRaises(ValueError): + build_connectivity_regex([]) + + +if __name__ == "__main__": + unittest.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..06de28d61f --- /dev/null +++ b/.github/scripts/runner.py @@ -0,0 +1,430 @@ +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 canonical_coin_name(coin: str, known_coins) -> str: + """Resolve a requested coin to its canonical key, accepting '_' where the + canonical (configs/coins) name uses '-'. + + Unlike normalize_coin_name this matches against the already-resolved coin + set instead of the filesystem, so build/deploy selection does not depend on + the process working directory (the context was built from the workspace). + """ + if coin in known_coins: + return coin + if "_" in coin: + candidate = coin.replace("_", "-") + if candidate in known_coins: + 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" + ) + + # Keep the test definitions but never deploy a coin flagged disabled (e.g. its + # backend/Blockbook is temporarily not deployed). Stays in sync with the + # disabled handling in tests/integration.go and tests/openapi/src/config.ts. + if test_cfg.get("disabled") is True: + return ( + f"coin '{coin}' maps to test coin '{lookup_coin}' " + "which is disabled 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 = [canonical_coin_name(coin, context.all_coins) 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 = [canonical_coin_name(coin, context.all_coins) 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..3dd1529e5f --- /dev/null +++ b/.github/scripts/runner_test.py @@ -0,0 +1,158 @@ +import tempfile +import unittest +from pathlib import Path + +from runner import ( + ValidationError, + canonical_coin_name, + 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") + + def test_deploy_rejects_disabled_coin_with_reason(self) -> None: + write_text( + self.workspace / "tests" / "tests.json", + '{"dogecoin":{"connectivity":{},"disabled":true},"base":{"connectivity":{}},' + '"polygon":{"connectivity":{}},"ethereum_classic":{"connectivity":{}}}', + ) + context = load_coin_context(self.workspace, self.valid_vars_map, include_deployability=True) + + self.assertNotIn("dogecoin", context.deployable_coins) + self.assertIn("disabled in tests/tests.json", context.deployability_errors["dogecoin"]) + + with self.assertRaisesRegex( + ValidationError, + "is disabled in tests/tests.json", + ): + resolve_deploy_selection(context, "dogecoin") + + def test_deploy_accepts_underscore_for_hyphenated_coin(self) -> None: + # Regression: deploy selection must resolve the "_" alias against the + # context's coins, not the process working directory. + context = load_coin_context(self.workspace, self.valid_vars_map, include_deployability=True) + + self.assertEqual(resolve_deploy_selection(context, "ethereum_classic"), ["ethereum-classic"]) + + def test_canonical_coin_name_preserves_underscore_native_coin(self) -> None: + # Pin the check ordering in canonical_coin_name: an underscore-native coin + # that exists as-is (e.g. base_archive, ethereum_testnet_sepolia) must be + # returned unchanged, NOT rewritten to a hyphen variant. Reordering the + # membership check after the "_"->"-" fallback would silently break + # selection of every such coin while leaving these tests green. + known = {"base_archive", "ethereum-classic"} + self.assertEqual(canonical_coin_name("base_archive", known), "base_archive") + # The "_"->"-" alias still resolves when the underscore form is not a coin. + self.assertEqual(canonical_coin_name("ethereum_classic", known), "ethereum-classic") + # An unknown coin with no hyphen alias is returned unchanged so the caller + # can reject it with a clear "unknown coin" error. + self.assertEqual(canonical_coin_name("nonexistent_coin", known), "nonexistent_coin") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/validate_backend_artifacts.py b/.github/scripts/validate_backend_artifacts.py new file mode 100644 index 0000000000..1212f4d7df --- /dev/null +++ b/.github/scripts/validate_backend_artifacts.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Lint backend artifact sources in configs/coins/*.json. + +Security policy (see CI/CD security review, finding M2): + + FAIL when a backend artifact download is not pinned to immutable, verified + content, specifically: + * a mutable source ref (a `…/raw//…` or `…/archive/refs/heads/…` + path, or any `refs/heads/…`) in `binary_url` *or* in `extract_command` + (some coins fetch a config file at build time); + * a `binary_url` with no integrity check at all (empty/absent + `verification_type` / `verification_source`); + * a plaintext `http://` `binary_url` that *also* lacks an integrity check. + + WARN (does not fail the build) when a `binary_url` is plaintext `http://` + but is checksum/signature verified. The content is integrity-protected, so + the residual risk is availability/trust rather than tampering; some of these + upstreams do not offer HTTPS. Surfaced so it stays visible. + +Immutable references that are allowed: pinned release tags (`…/download//…`, +`…/archive/refs/tags/…`) and 40-hex commit SHAs. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +COINS_DIR = REPO_ROOT / "configs" / "coins" + +VALID_VERIFICATION_TYPES = {"sha256", "gpg", "gpg-sha256", "docker"} + +COMMIT_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") +# `raw.githubusercontent.com////…` and +# `github.com///raw//…` — capture the ref segment. +RAW_HOST_RE = re.compile(r"raw\.githubusercontent\.com/[^/\s]+/[^/\s]+/([^/\s]+)/") +RAW_PATH_RE = re.compile(r"github\.com/[^/\s]+/[^/\s]+/raw/([^/\s]+)/") + + +def _ref_is_mutable(ref: str) -> bool: + # A full commit SHA is immutable; a branch name (master/develop/…) is not. + # `refs` is the first segment of `refs/heads|tags/…`, handled separately. + return ref != "refs" and not COMMIT_SHA_RE.match(ref) + + +def is_mutable(text: str) -> bool: + """True if `text` references a mutable git branch (vs a tag or commit SHA). + + Catches both `binary_url`s and build-time fetches embedded in + `extract_command`. Pinned commit SHAs and `refs/tags/...` are immutable. + """ + if not text: + return False + # Explicit branch refs: `refs/heads/...`, `/archive/refs/heads/...`. + if re.search(r"refs/heads/", text) or re.search(r"/archive/refs/heads/", text): + return True + for pattern in (RAW_HOST_RE, RAW_PATH_RE): + for match in pattern.finditer(text): + if _ref_is_mutable(match.group(1)): + return True + return False + + +def has_integrity(backend: dict) -> bool: + vtype = (backend.get("verification_type") or "").strip() + vsource = (backend.get("verification_source") or "").strip() + return vtype in VALID_VERIFICATION_TYPES and vtype != "" and vsource != "" + + +def _is_go_zero_value(value: object) -> bool: + """Return whether a JSON value maps to a zero Go struct field value.""" + if value is None: + return True + if isinstance(value, bool): + return not value + if isinstance(value, (int, float)): + return value == 0 + if isinstance(value, str): + return value == "" + return False + + +def merge_backend_platform(base: dict, override: dict) -> dict: + """Match build/tools LoadConfig platform override behavior. + + The Go loader copies non-zero fields from backend.platforms. onto + the top-level backend. Empty strings, false, zero, and null do not override + top-level defaults. + """ + merged = dict(base) + for key, value in override.items(): + if not _is_go_zero_value(value): + merged[key] = value + return merged + + +def effective_backends(backend: dict) -> list[tuple[str, dict]]: + backends = [("backend", backend)] + platforms = backend.get("platforms") + if not isinstance(platforms, dict): + return backends + + for platform, override in sorted(platforms.items()): + if isinstance(override, dict): + backends.append( + (f"backend.platforms.{platform}", merge_backend_platform(backend, override)) + ) + + return backends + + +def _with_context(context: str, message: str) -> str: + if context == "backend": + return message + return f"{context}: {message}" + + +def lint_backend(context: str, backend: dict) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + + binary_url = (backend.get("binary_url") or "").strip() + extract_command = backend.get("extract_command") or "" + + # A build-time fetch of a config/asset from a mutable branch is just as + # dangerous as a mutable binary_url. + if is_mutable(binary_url): + errors.append( + _with_context(context, f"binary_url uses a mutable branch ref: {binary_url}") + ) + if is_mutable(extract_command): + errors.append( + _with_context( + context, + "extract_command fetches from a mutable branch ref (pin to a commit SHA)", + ) + ) + + if not binary_url: + return errors, warnings + + integrity = has_integrity(backend) + if not integrity: + errors.append( + _with_context( + context, + "binary_url has no integrity check " + "(set verification_type + verification_source)", + ) + ) + + if binary_url.lower().startswith("http://"): + if integrity: + warnings.append( + _with_context( + context, + f"binary_url uses plaintext HTTP (checksum-verified): {binary_url}", + ) + ) + else: + errors.append( + _with_context( + context, + f"binary_url uses plaintext HTTP with no integrity check: {binary_url}", + ) + ) + + return errors, warnings + + +def lint_file(path: Path) -> tuple[list[str], list[str]]: + """Return (errors, warnings) for a single coin config.""" + errors: list[str] = [] + warnings: list[str] = [] + try: + config = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + return [f"could not parse: {exc}"], [] + + backend = config.get("backend") + if not isinstance(backend, dict): + return errors, warnings + + for context, effective_backend in effective_backends(backend): + backend_errors, backend_warnings = lint_backend(context, effective_backend) + errors.extend(backend_errors) + warnings.extend(backend_warnings) + + return errors, warnings + + +def main() -> int: + if not COINS_DIR.is_dir(): + print(f"error: {COINS_DIR} not found", file=sys.stderr) + return 2 + + total_errors = 0 + total_warnings = 0 + for path in sorted(COINS_DIR.glob("*.json")): + errors, warnings = lint_file(path) + for msg in warnings: + print(f"WARN {path.name}: {msg}") + total_warnings += 1 + for msg in errors: + print(f"FAIL {path.name}: {msg}", file=sys.stderr) + total_errors += 1 + + print( + f"\nbackend artifact lint: {total_errors} error(s), {total_warnings} warning(s)", + file=sys.stderr, + ) + return 1 if total_errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/validate_backend_artifacts_test.py b/.github/scripts/validate_backend_artifacts_test.py new file mode 100644 index 0000000000..e33a5a52a7 --- /dev/null +++ b/.github/scripts/validate_backend_artifacts_test.py @@ -0,0 +1,48 @@ +import json +import tempfile +import unittest +from pathlib import Path + +import validate_backend_artifacts + + +class BackendArtifactLintTest(unittest.TestCase): + def lint_config(self, backend: dict) -> tuple[list[str], list[str]]: + with tempfile.TemporaryDirectory() as tempdir: + path = Path(tempdir) / "coin.json" + path.write_text(json.dumps({"backend": backend}), encoding="utf-8") + return validate_backend_artifacts.lint_file(path) + + def test_lints_platform_backend_after_merging_top_level_defaults(self) -> None: + errors, warnings = self.lint_config( + { + "binary_url": ( + "https://example.com/releases/download/v1.0.0/backend-linux-amd64.tar.gz" + ), + "verification_type": "sha256", + "verification_source": "abc123", + "extract_command": "tar -C backend --strip 1 -xf", + "platforms": { + "arm64": { + "binary_url": ( + "https://raw.githubusercontent.com/example/backend/main/" + "backend-linux-arm64.tar.gz" + ) + } + }, + } + ) + + self.assertEqual(warnings, []) + self.assertEqual( + errors, + [ + "backend.platforms.arm64: binary_url uses a mutable branch ref: " + "https://raw.githubusercontent.com/example/backend/main/" + "backend-linux-arm64.tar.gz" + ], + ) + + +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..41c4770396 --- /dev/null +++ b/.github/scripts/wait_for_sync.py @@ -0,0 +1,249 @@ +#!/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 build_ssl_context() -> tuple[ssl.SSLContext, str]: + # Dev Blockbook instances are reached over HTTPS, usually with an + # internally-issued (often self-signed) certificate, so chain verification + # is opt-in (see security_report_final.md L1): + # * SYNC_CA_FILE= verify the chain against that internal CA bundle + # (preferred for internal hosts); + # * SYNC_TLS_INSECURE=0 verify against the system trust store; + # * otherwise verification is disabled (default; preserves the + # prior behavior for self-signed dev certs). + ca_file = os.environ.get("SYNC_CA_FILE", "").strip() + if ca_file: + return ssl.create_default_context(cafile=ca_file), f"verified against {ca_file}" + insecure = os.environ.get("SYNC_TLS_INSECURE", "1").strip().lower() + if insecure not in ("1", "true", "yes", "on"): + return ssl.create_default_context(), "verified against the system trust store" + return ( + ssl._create_unverified_context(), + "DISABLED (set SYNC_CA_FILE to pin an internal CA)", + ) + + +def fetch_status( + base_url: str, request_timeout: int, context: ssl.SSLContext +) -> tuple[int, bytes]: + request = urllib.request.Request(base_url + "/api/status") + 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")) + + ssl_context, tls_mode = build_ssl_context() + log(f"TLS certificate verification: {tls_mode}") + + 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, ssl_context) + 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, ssl_context) + 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..c1e2177174 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,269 @@ +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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Validate branch or tag + env: + REF_TO_VALIDATE: ${{ env.RESOLVED_BRANCH_OR_TAG }} + run: python3 ./.github/scripts/validate_branch_or_tag.py --repo "$GITHUB_REPOSITORY" --ref "$REF_TO_VALIDATE" + + - name: Checkout requested branch or tag + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + 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 }} + connectivity_regex: ${{ steps.plan.outputs.connectivity_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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Validate branch or tag + env: + REF_TO_VALIDATE: ${{ env.RESOLVED_BRANCH_OR_TAG }} + run: python3 ./.github/scripts/validate_branch_or_tag.py --repo "$GITHUB_REPOSITORY" --ref "$REF_TO_VALIDATE" + + - name: Checkout requested branch or tag + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + 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 }} + BACKEND_MODE: ${{ inputs.backend_mode }} + # Space-separated, allowlisted coin aliases; intentionally word-split below. + COINS: ${{ join(matrix.coins, ' ') }} + run: python3 ./.github/scripts/build_packages.py --backend-mode "$BACKEND_MODE" $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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + + - name: Export repository variables + uses: ./.github/actions/export-env-vars + with: + vars_json: ${{ toJSON(vars) }} + + - name: Materialize blockbook runtime env + env: + BB_ENV: ${{ secrets.BB_RUNTIME_ENV }} + run: | + if [ -z "$BB_ENV" ]; then + echo "BB_RUNTIME_ENV secret not set; leaving any existing /etc/blockbook/blockbook.env untouched" + exit 0 + fi + sudo install -d -m 0755 -o root -g root /etc/blockbook + # Pre-create restricted so secrets are never briefly world-readable; + # tee truncates the existing file without changing its 0600 mode. + sudo install -m 0600 -o root -g root /dev/null /etc/blockbook/blockbook.env + printf '%s\n' "$BB_ENV" | sudo tee /etc/blockbook/blockbook.env >/dev/null + + - 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + 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 }} + CONNECTIVITY_REGEX: ${{ needs.prepare_deploy.outputs.connectivity_regex }} + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ env.RESOLVED_BRANCH_OR_TAG }} + + - name: Export repository variables + uses: ./.github/actions/export-env-vars + with: + vars_json: ${{ toJSON(vars) }} + + - name: Run Blockbook connectivity tests + env: + BB_BUILD_ENV: dev + CONNECTIVITY_REGEX: ${{ env.CONNECTIVITY_REGEX }} + # The freshly deployed nodes are reachable here, so verify backend RPC + # connectivity in addition to Blockbook (local runs skip the node checks). + BB_TEST_BACKEND_CONNECTIVITY: "1" + run: make test-connectivity ARGS="-v" + + - name: Setup Node.js for OpenAPI tests + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: tests/openapi/package-lock.json + + - name: Cache OpenAPI test dependencies + id: openapi-node-modules-cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: tests/openapi/node_modules + key: openapi-node-modules-node22-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('tests/openapi/package-lock.json') }} + restore-keys: | + openapi-node-modules-node22-${{ runner.os }}-${{ runner.arch }}- + + - name: Install OpenAPI test dependencies + if: steps.openapi-node-modules-cache.outputs.cache-hit != 'true' + run: npm ci --prefix tests/openapi --prefer-offline --no-audit --no-fund + + - name: Run OpenAPI API e2e tests + env: + BB_BUILD_ENV: dev + OPENAPI_COINS: ${{ needs.prepare_deploy.outputs.test_coins_csv }} + OPENAPI_JUNIT_PATH: ${{ github.workspace }}/tests/openapi/reports/junit.xml + run: contrib/tests/run-openapi-tests.sh + + # Upload even on failure: the runner writes the report before exiting non-zero, and a failed + # run is exactly when the per-test breakdown (which coin/test failed or skipped) is most useful. + - name: Upload OpenAPI e2e report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: openapi-e2e-report + path: tests/openapi/reports/ + if-no-files-found: warn diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000000..c86ab1a3da --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,107 @@ +name: Testing + +on: + push: + branches: [ master, develop ] + pull_request: + branches: [ '**' ] + +permissions: + contents: read + +jobs: + unit-tests: + name: Unit Tests + runs-on: [self-hosted, bb-dev-selfhosted] + # Defense-in-depth: never run untrusted fork-PR code on the self-hosted + # runner. connectivity/integration already carry this guard; without it + # here, unit-tests relied solely on the repo "require approval for fork + # PR workflows" setting. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Export repository variables + uses: ./.github/actions/export-env-vars + with: + vars_json: ${{ toJSON(vars) }} + + - name: Run connectivity tests + env: + BB_BUILD_ENV: dev + # CI can reach the node RPC backends directly, so check them too; local + # runs default to Blockbook-only connectivity. + BB_TEST_BACKEND_CONNECTIVITY: "1" + 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - 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" + + lint: + name: Lint + # Pure static analysis of configs/ (Grafana dashboards + coin backend + # artifacts): no secrets, no backend access, safe to run for fork PRs on an + # ephemeral GitHub-hosted runner. Unlike unit-tests (self-hosted, skipped for + # fork PRs), this job runs for forks too, so dashboard/artifact regressions + # are caught on every PR. The only third-party dependency is the pinned + # PyYAML used by the Grafana renderer. + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Install Python dependencies + run: | + rm -rf .python-deps + python3 -c "import yaml" 2>/dev/null || python3 -m pip install --disable-pip-version-check --quiet --target .python-deps -r .github/requirements.txt + + - name: Validate Grafana dashboard sources + run: | + PYTHONPATH=.python-deps python3 contrib/scripts/render_grafana_test.py + PYTHONPATH=.python-deps python3 contrib/scripts/render_grafana.py --check + + - name: Validate backend artifact sources + run: python3 ./.github/scripts/validate_backend_artifacts.py diff --git a/.gitignore b/.gitignore index 98fe00b344..551c45eaaf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,10 @@ build/*.deb .bin-image .deb-image \.idea/ +__debug* +.gocache/ +__pycache__/ +.dev/ +.spec/ +static/api-docs/swagger-ui/ +configs/grafana/grafana.json \ 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/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..2beed2b800 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ +# Agent instructions + +## Testing + +Test your changes before declaring done. Start with unit tests : +``` +contrib/tests/run-unit-tests.sh [go args] +``` +Then connectivity tests : +``` +contrib/tests/run-integration-tests.sh -run 'TestIntegration/.*/connectivity' # make sure we can reach Blockbook +``` +Locally the connectivity tests only check Blockbook reachability; the raw +backend/node RPC checks need direct node access (only routable from CI) and are +skipped. Add `--backend-connectivity` (or export `BB_TEST_BACKEND_CONNECTIVITY=1`) +to run them too — CI sets this automatically. +Then continue with integration tests, but start narrow, broaden only when needed : +``` +contrib/tests/run-integration-tests.sh -run 'TestIntegration/ethereum=main/rpc/GetBlock' +contrib/tests/run-integration-tests.sh -run 'TestIntegration/ethereum=main/rpc' +contrib/tests/run-integration-tests.sh -run 'TestIntegration/ethereum=main' +``` +Test path convention : +``` +TestIntegration/=main|test[]//[/] +``` + +- Avoid bitcoin during iteration : `bitcoin=main`'s `MempoolSync` and `GetTransactionForMempool` walk mainnet's +mempool and are slow, prefer `bcash`. +- Prefer `ethereum`, `bsc`, `tron`, and `avalanche` from the EVM family. +- The coin segment comes from a `tests/tests.json` key: keys containing +`_testnet` map to `=test` (the network suffix after `_testnet` +is preserved), the rest to `=main` (`bitcoin_regtest` → +`bitcoin_regtest=main`). So `bitcoin_testnet` → `bitcoin=test`, +`bitcoin_testnet4` → `bitcoin=test4`, `ethereum_testnet_sepolia` → +`ethereum=test_sepolia`, `tron_testnet_nile` → `tron=test_nile`. The mapping is +injective (distinct keys never collide), so no `#01`/`#02` disambiguation is +needed. Keep `getMatchableName()` in `tests/integration.go` and +`matchable_name()` in `.github/scripts/deploy_plan.py` in sync. +- To temporarily retire a coin whose backend/Blockbook is not deployed without +deleting its test definitions, add `"disabled": true` to its `tests/tests.json` +entry. It is then skipped by the Go integration suite (`tests/integration.go`), +the OpenAPI e2e selection (`tests/openapi/src/config.ts`), and deploy planning +(`.github/scripts/runner.py`). Remove the flag to re-enable. +- in case of unexpected integration test failures, you can run `contrib/scripts/blockbook_status.sh` or `contrib/scripts/backend_status.sh` +scripts to check health of particular blockbook/backend instance +- `contrib/gh-vars.sh` has a `_BB_GH_CACHE_VERSION` variable that must be bumped when the cache file format changes (e.g. the schema header or the structure of the exported env file) to invalidate stale caches +- `contrib/tests/run-all-tests.sh` is for CI/CD only — too slow for an agent feedback loop, do not run it. +- `-count=1` bypasses the test cache. Use it when you suspect stale results: `go test` + fingerprints test binary + args, but it does NOT notice when GitHub Actions repository + variables (the URLs/credentials your tests dial) change between runs, so a previously + cached PASS can mask a real failure. + +## Profiling + +Profiling is enabled only on Dev blockbooks. When troubleshooting performance, slow sync, +large mempool handling, stuck goroutines, or suspected deadlocks, use: +``` +contrib/scripts/blockbook_profile.sh [--profile cpu|heap|goroutine|allocs|threadcreate] +``` + +The script loads Dev Blockbook URLs via `contrib/gh-vars.sh`, derives the pprof port from +the coin config, prints a compact sync/metrics snapshot, downloads the selected pprof +profile, and runs `go tool pprof -top`. Start with CPU for throughput issues and +`--profile goroutine` for deadlock/stall investigations. + +## Metrics + +Prometheus metrics and the Grafana dashboard share one source of truth, `configs/metrics.yaml`. + +- **Add a metric:** add an entry to `configs/metrics.yaml` (stable key + `name`/`type`/`help`; + `labels` for `*_vec`, `buckets` for histograms), then a `Metrics` field in `common/metrics.go` tagged `metric:""`. +- **Add a panel:** add the viz skeleton (type/`fieldConfig`/`options`, new `id` + a semantic + `x-panel-key`, and an `x-query-key` per target — no `gridPos` or `datasource`) to + `configs/grafana/template.json`, then its `title`/`description`/`queries` under that `x-panel-key` + in `configs/grafana/panels.yaml` (queries keyed by `x-query-key`, each with `promql`/`legend`; + write metric names as `{{name:}}`). Panels pack left-to-right in `template.json` order at 8×8; + set `width`/`height` in the panels.yaml entry to override. +- Prefer stable panel keys like `
.[_stat]` (for example `rpc.request_duration_p95`) + and query keys that name the plotted series (`requests`, `errors`, `p95`, `total`, `threshold`). +- After any of these, run `python3 contrib/scripts/render_grafana.py` (CI gates with `--check`). + +## Facts to keep in mind to avoid regressions and waste + +- Blockbook instance should be able to : + - handle at least 20 000 websocket connections from trezor suite + - index and catchup with fast L2 chains like Arbitrum or Base +- ignore and do not open following directories and files unless really necessary : + - `tests/openapi/node_modules/` + - `tests/openapi/package-lock.json` + - `./configs/grafana/grafana.json` +- keep inline comments max one paragraph big with pure logic reasoning \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3fb51b227a..43fe3f9f5b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ Instructions to set up your development environment and build Blockbook are desc A great way to contribute to the project is to send a detailed report when you encounter a problem. We always appreciate a well-written and thorough bug report, and we'll be grateful for it! -Check that [our issue database](https://github.com/syscoin/blockbook/issues) doesn't already include that problem or +Check that [our issue database](https://github.com/trezor/blockbook/issues) doesn't already include that problem or suggestion before submitting an issue. If you find a match, you can use the "subscribe" button to get notified on updates. Do not leave random "+1" or "I have this too" comments, as they only clutter the discussion, and don't help resolving it. However, if you have ways to reproduce the issue or have additional information that may help resolving @@ -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..f73da991b3 100644 --- a/Makefile +++ b/Makefile @@ -1,39 +1,70 @@ BIN_IMAGE = blockbook-build DEB_IMAGE = blockbook-build-deb PACKAGER = $(shell id -u):$(shell id -g) +DOCKER_VERSION = 29.4.3 +DOCKER_SHA256 = bc9734a89d3edd15eeca8620961f6499ba69948814c85d7ac3488e34b3e16d01 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 the build-time override variables into Docker for build/test tooling. +# The prefix set is the single source of truth in build/bb-build-var-prefixes.txt, +# shared with .github/actions/export-env-vars and build/tools/templates.go. +# (BB_BUILD_ENV is forwarded separately via -e BB_BUILD_ENV=... on each docker run.) +BB_VAR_PREFIX_FILE := build/bb-build-var-prefixes.txt +BB_RPC_ENV := $(shell env | awk -F= 'NR==FNR{line=$$0;gsub(/[[:space:]]/,"",line);if(substr(line,1,3)=="BB_")pfx[line]=1;next}{for(p in pfx){if(index($$1,p)==1){print "-e " $$1;next}}}' $(BB_VAR_PREFIX_FILE) -) 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)" + +# `make test-e2e [coin] [url]` — e.g. `make test-e2e bitcoin https://btc.trezor.io`. +# Extra goals after test-e2e are consumed as positional args (coin, base URL) +# and turned into OPENAPI_COINS / BB_DEV_API_URL_HTTP_ for the test runner. +ifeq (test-e2e,$(firstword $(MAKECMDGOALS))) + E2E_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) + ifneq ($(E2E_ARGS),) + # no-op catch-all so make does not try to build the positional args as targets +%: + @: + endif +endif + +test-e2e: + @if [ ! -x tests/openapi/node_modules/.bin/redocly ]; then npm ci --prefix tests/openapi --prefer-offline --no-audit --no-fund; fi + @coin="$(word 1,$(E2E_ARGS))"; url="$(word 2,$(E2E_ARGS))"; \ + if [ -n "$$coin" ]; then export OPENAPI_COINS="$$coin"; fi; \ + if [ -n "$$url" ]; then export "BB_DEV_API_URL_HTTP_$$(printf %s "$$coin" | tr - _)=$$url"; fi; \ + contrib/tests/run-openapi-tests.sh + +test-connectivity: .bin-image + docker run -t --rm -e PACKAGER=$(PACKAGER) -e BB_BUILD_ENV=$(BB_BUILD_ENV) -e GITCOMMIT=$(GITCOMMIT) -e CONNECTIVITY_REGEX -e BB_TEST_BACKEND_CONNECTIVITY $(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) -e BB_TEST_BACKEND_CONNECTIVITY $(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 +86,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) --build-arg DOCKER_SHA256=$(DOCKER_SHA256) -t $(DEB_IMAGE) build/docker/deb; \ else \ echo "Image $(DEB_IMAGE) is up to date"; \ fi @@ -79,3 +110,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 9f3f16c93f..972400e5cd 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,15 @@ -[![Go Report Card](https://goreportcard.com/badge/syscoin/blockbook)](https://goreportcard.com/report/syscoin/blockbook) +[![Go Report Card](https://goreportcard.com/badge/trezor/blockbook)](https://goreportcard.com/report/trezor/blockbook) # 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: -- full support for Syscoin SPT’s through indexing and filtering transactions -- 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 (Syscoin/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 @@ -20,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). @@ -28,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, Syscoin +- 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, Syscoin Testnet + +- 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). @@ -43,39 +43,29 @@ 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/syscoin/blockbook/issues/43). +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/syscoin/blockbook/issues/89) or [this](https://github.com/syscoin/blockbook/issues/147) issue for more info. +Check [this](https://github.com/trezor/blockbook/issues/89) or [this](https://github.com/trezor/blockbook/issues/147) issue for more info. #### Running on Ubuntu -[This issue](https://github.com/syscoin/blockbook/issues/45) discusses how to run Blockbook on Ubuntu. If you have some additional experience with Blockbook on Ubuntu, please add it to [this issue](https://github.com/syscoin/blockbook/issues/45). +[This issue](https://github.com/trezor/blockbook/issues/45) discusses how to run Blockbook on Ubuntu. If you have some additional experience with Blockbook on Ubuntu, please add it to [this issue](https://github.com/trezor/blockbook/issues/45). #### My coin implementation is reporting parse errors when importing blockchain -Your coin's block/transaction data may not be compatible with `BitcoinParser` `ParseBlock`/`ParseTx`, which is used by default. In that case, implement your coin in a similar way we used in case of [zcash](https://github.com/syscoin/blockbook/tree/master/bchain/coins/zec) and some other coins. The principle is not to parse the block/transaction data in Blockbook but instead to get parsed transactions as json from the backend. - -#### Cannot build Blockbook using `go build` command - -When building Blockbook I get error `not enough arguments in call to _Cfunc_rocksdb_approximate_sizes`. - -RocksDB version 6.16.0 changed the API in a backwards incompatible way. It is necessary to build Blockbook with the `rocksdb_6_16` tag to fix the compatibility problem. The correct way to build Blockbook is: - -``` -go build -tags rocksdb_6_16 -``` +Your coin's block/transaction data may not be compatible with `BitcoinParser` `ParseBlock`/`ParseTx`, which is used by default. In that case, implement your coin in a similar way we used in case of [zcash](https://github.com/trezor/blockbook/tree/master/bchain/coins/zec) and some other coins. The principle is not to parse the block/transaction data in Blockbook but instead to get parsed transactions as json from the backend. ## Data storage in RocksDB @@ -84,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/SYSCOIN_5_API_UPDATES.md b/SYSCOIN_5_API_UPDATES.md index 4f33c45c91..34e3bf5faa 100644 --- a/SYSCOIN_5_API_UPDATES.md +++ b/SYSCOIN_5_API_UPDATES.md @@ -40,7 +40,7 @@ ### 4. Updated Address Endpoint - Added SPT token information in address responses - Example shows `tokens` array with SPT assets -- Token type: "SPTAllocated" +- Token type: "SPTAllocated" - Includes asset GUID, symbol, decimals, balances, and transfer counts ### 5. Enhanced Query Parameters @@ -97,4 +97,4 @@ The API documentation now accurately reflects: - Monitor syscoinjs-lib updates for client-side compatibility - Ensure API responses match the latest Syscoin Core 5.0 specifications - Consider adding more detailed error responses for asset-related operations -- Document any additional NEVM-specific endpoints that may be added \ No newline at end of file +- Document any additional NEVM-specific endpoints that may be added 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/embed/about b/api/embed/about index 71c44dc9b0..79156218b0 100644 --- a/api/embed/about +++ b/api/embed/about @@ -1 +1 @@ -Blockbook - blockchain indexer for Trezor wallet https://trezor.io/. Do not use for any other purpose. +Blockbook - blockchain indexer for Trezor Suite https://trezor.io/trezor-suite. Do not use for any other purpose. diff --git a/api/embed/tos_link b/api/embed/tos_link index 9b16245e61..8c90089b92 100644 --- a/api/embed/tos_link +++ b/api/embed/tos_link @@ -1 +1 @@ -https://shop.trezor.io/static/shared/about/terms-of-use.pdf +https://trezor.io/terms-of-use 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..ba44dd3f74 --- /dev/null +++ b/api/fiat_rates_api.go @@ -0,0 +1,213 @@ +package api + +import ( + "fmt" + "sort" + "strings" + + "github.com/golang/glog" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +// MaxFiatRatesTimestamps limits batch fiat-rate lookups to bounded request work. +const MaxFiatRatesTimestamps = 1000 + +// 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) + } + if len(timestamps) > MaxFiatRatesTimestamps { + return nil, NewAPIError(fmt.Sprintf("too many timestamps, max %d", MaxFiatRatesTimestamps), 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..90df2b00e5 --- /dev/null +++ b/api/fiat_rates_api_test.go @@ -0,0 +1,346 @@ +//go:build unittest + +package api + +import ( + "fmt" + "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_LimitsInput(t *testing.T) { + w := &Worker{} + timestamps := make([]int64, MaxFiatRatesTimestamps+1) + _, err := w.GetFiatRatesForTimestamps(timestamps, []string{"usd"}, "") + apiErr := requireAPIError(t, err, true) + want := fmt.Sprintf("too many timestamps, max %d", MaxFiatRatesTimestamps) + if apiErr.Text != want { + t.Fatalf("unexpected error text: got %q, want %q", apiErr.Text, want) + } +} + +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 10af9b2e23..c4c2a63463 100644 --- a/api/types.go +++ b/api/types.go @@ -3,13 +3,15 @@ package api import ( "encoding/json" "errors" + "fmt" "math/big" "sort" + "strings" "time" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/eth" - "github.com/syscoin/blockbook/common" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" ) const maxUint32 = ^uint32(0) @@ -39,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 { @@ -55,130 +57,366 @@ func NewAPIError(s string, public bool) error { } } -// IsZeroBigInt if big int has zero value +// Amount is a datatype holding amounts +type Amount big.Int + +// IsZeroBigInt checks if big int has zero value func IsZeroBigInt(b *big.Int) bool { return len(b.Bits()) == 0 } -type AssetInfo struct { - AssetGuid string `json:"assetGuid,omitempty"` - ValueSat *bchain.Amount `json:"value,omitempty"` - ValueStr string `json:"valueStr,omitempty"` +// 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 +func (a *Amount) Compare(b *Amount) int { + if b == nil { + if a == nil { + return 0 + } + return 1 + } + if a == nil { + return -1 + } + return (*big.Int)(a).Cmp((*big.Int)(b)) } -// 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 *bchain.Amount `json:"value,omitempty"` - Hex string `json:"hex,omitempty"` - Asm string `json:"asm,omitempty"` - Coinbase string `json:"coinbase,omitempty"` - AssetInfo *AssetInfo `json:"assetInfo,omitempty"` +// MarshalJSON Amount serialization +func (a *Amount) MarshalJSON() (out []byte, err error) { + if a == nil { + return []byte(`"0"`), nil + } + return []byte(`"` + (*big.Int)(a).String() + `"`), nil } -// Vout contains information about single transaction output -type Vout struct { - ValueSat *bchain.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"` - AssetInfo *AssetInfo `json:"assetInfo,omitempty"` -} - -// Contains SyscoinSpecific asset information hex decoded and pertinent to API display +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 "" + } + return (*big.Int)(a).String() +} + +// DecimalString returns amount with decimal point placed according to parameter d +func (a *Amount) DecimalString(d int) string { + return bchain.AmountToDecimalString((*big.Int)(a), d) +} + +// AsBigInt returns big.Int type for the Amount (empty if Amount is nil) +func (a *Amount) AsBigInt() big.Int { + if a == nil { + return *new(big.Int) + } + return big.Int(*a) +} + +// AsInt64 returns Amount as int64 (0 if Amount is nil). +// It is generally not recommended to use for possible loss of precision. +func (a *Amount) AsInt64() int64 { + if a == nil { + return 0 + } + return (*big.Int)(a).Int64() +} + +// SYSCOIN: AssetInfo contains SPT metadata for a transaction input, output, or +// UTXO. +type AssetInfo struct { + AssetGuid string `json:"assetGuid,omitempty" ts_doc:"Syscoin SPT asset GUID."` + ValueSat *Amount `json:"value,omitempty" ts_doc:"SPT amount in base units."` + ValueStr string `json:"valueStr,omitempty" ts_doc:"SPT amount as a decimal string."` + Symbol string `json:"symbol,omitempty" ts_doc:"Syscoin SPT asset symbol, if available."` +} + +// SYSCOIN: AssetSpecific contains Syscoin SPT metadata exposed by asset +// endpoints and explorer pages. type AssetSpecific struct { - AssetGuid string `json:"assetGuid"` - Contract string `json:"contract,omitempty"` - Symbol string `json:"symbol"` - TotalSupply *bchain.Amount `json:"totalSupply"` - MaxSupply *bchain.Amount `json:"maxSupply"` - Decimals int `json:"decimals"` - MetaData string `json:"metaData,omitempty"` + AssetGuid string `json:"assetGuid" ts_doc:"Syscoin SPT asset GUID."` + Contract string `json:"contract,omitempty" ts_doc:"NEVM contract address associated with this asset, if any."` + Symbol string `json:"symbol" ts_doc:"Asset symbol."` + TotalSupply *Amount `json:"totalSupply" ts_doc:"Current total supply in base units."` + MaxSupply *Amount `json:"maxSupply" ts_doc:"Maximum supply in base units."` + Decimals int `json:"decimals" ts_doc:"Number of asset decimal places."` + MetaData string `json:"metaData,omitempty" ts_doc:"Raw Syscoin asset metadata."` } -// Contains SyscoinSpecific assets information when searching for assets +// SYSCOIN: AssetsSpecific is the compact Syscoin SPT row used by asset search. type AssetsSpecific struct { - AssetGuid string `json:"assetGuid"` - Contract string `json:"contract"` - Symbol string `json:"symbol"` - TotalSupply *bchain.Amount `json:"totalSupply"` - Decimals int `json:"precision"` - Txs int - MetaData string `json:"metaData,omitempty"` + AssetGuid string `json:"assetGuid" ts_doc:"Syscoin SPT asset GUID."` + Contract string `json:"contract" ts_doc:"NEVM contract address associated with this asset, if any."` + Symbol string `json:"symbol" ts_doc:"Asset symbol."` + TotalSupply *Amount `json:"totalSupply" ts_doc:"Current total supply in base units."` + Decimals int `json:"precision" ts_doc:"Number of asset decimal places."` + Txs int `json:"txs" ts_doc:"Number of indexed transactions touching this asset."` + MetaData string `json:"metaData,omitempty" ts_doc:"Raw Syscoin asset metadata."` } -// EthereumSpecific contains ethereum specific transaction data -type EthereumSpecific struct { - Status eth.TxStatus `json:"status"` // 1 OK, 0 Fail, -1 pending - Nonce uint64 `json:"nonce"` - GasLimit *big.Int `json:"gasLimit"` - GasUsed *big.Int `json:"gasUsed"` - GasPrice *bchain.Amount `json:"gasPrice"` - Data string `json:"data,omitempty"` +// Vin contains information about single transaction input +type Vin struct { + 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)."` + // SYSCOIN: SPT metadata for this input. + AssetInfo *AssetInfo `json:"assetInfo,omitempty" ts_doc:"Syscoin SPT metadata for this input."` } +// Vout contains information about single transaction output +type Vout struct { + 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')."` + // SYSCOIN: SPT metadata for this output. + AssetInfo *AssetInfo `json:"assetInfo,omitempty" ts_doc:"Syscoin SPT metadata for this output."` +} + +// MultiTokenValue contains values for contracts with multiple token IDs +type MultiTokenValue struct { + 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 { + // 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" ts_doc:"Number of decimals for this token. Always present; defaults to the coin convention (18 for ERC-20) when the contract value is unavailable."` + 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."` + // SYSCOIN: SPT balance fields. + UnconfirmedBalanceSat *Amount `json:"unconfirmedBalance,omitempty" ts_doc:"Unconfirmed token balance delta."` + UnconfirmedTransfers int `json:"unconfirmedTransfers,omitempty" ts_doc:"Number of unconfirmed Syscoin SPT transfers."` + AssetGuid string `json:"assetGuid,omitempty" ts_doc:"Syscoin SPT asset GUID."` + Protocols TokenProtocols `json:"protocols,omitempty" ts_type:"string[]" 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 +type Tokens []Token + +func (a Tokens) Len() int { return len(a) } +func (a Tokens) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a Tokens) Less(i, j int) bool { + ti := &a[i] + tj := &a[j] + // sort by BaseValue descending and then Name and then by Contract + if ti.BaseValue < tj.BaseValue { + return false + } else if ti.BaseValue > tj.BaseValue { + return true + } + if ti.Name == "" { + if tj.Name != "" { + return false + } + } else { + if tj.Name == "" { + return true + } + return ti.Name < tj.Name + } + return ti.Contract < tj.Contract +} + +// TokenTransfer contains info about a token transfer done in a transaction +type TokenTransfer 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'"` + 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" ts_doc:"Number of decimals for this token. Always present; defaults to the coin convention (18 for ERC-20) when the contract value is unavailable."` + 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."` + // SYSCOIN: SPT asset GUID for Bitcoin-type Syscoin token summaries. + AssetGuid string `json:"assetGuid,omitempty" ts_doc:"Syscoin SPT asset GUID."` +} + +// EthereumInternalTransfer represents internal transaction data in Ethereum-like blockchains +type EthereumInternalTransfer struct { + 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 +type EthereumSpecific struct { + 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 `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"` - Blocktime int64 `json:"blockTime"` - Size int `json:"size,omitempty"` - ValueOutSat *bchain.Amount `json:"value"` - ValueInSat *bchain.Amount `json:"valueIn,omitempty"` - FeesSat *bchain.Amount `json:"fees,omitempty"` - Hex string `json:"hex,omitempty"` - Rbf bool `json:"rbf,omitempty"` - CoinSpecificData json.RawMessage `json:"coinSpecificData,omitempty"` - TokenTransferSummary []*bchain.TokenTransferSummary `json:"tokenTransfers,omitempty"` - TokenType *bchain.TokenType `json:"tokenType,omitempty"` - EthereumSpecific *EthereumSpecific `json:"ethereumSpecific,omitempty"` - Memo []byte `json:"memo,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."` + // SYSCOIN: SPT transaction type and optional decoded memo. + TokenType *bchain.TokenType `json:"tokenType,omitempty" ts_doc:"Syscoin SPT transaction type."` + Memo []byte `json:"memo,omitempty" ts_doc:"Syscoin SPT memo decoded from OP_RETURN."` + 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 *bchain.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 type TokensToReturn int -type TokenMempoolInfo struct { - Used bool - UnconfirmedTxs int - ValueSat *big.Int -} const ( // AddressFilterVoutOff disables filtering of transactions by vout @@ -200,88 +438,122 @@ 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'."` + // SYSCOIN: SPT transaction type bitmask filter. + AssetsMask bchain.AssetsMask `ts_doc:"Syscoin SPT transaction type bitmask filter."` // OnlyConfirmed set to true will ignore mempool transactions; mempool is also ignored if FromHeight/ToHeight filter is specified - OnlyConfirmed bool - AssetsMask bchain.AssetsMask -} - -// Address holds information about address and its transactions + OnlyConfirmed bool `ts_doc:"If true, ignores mempool (unconfirmed) transactions."` + // WithConfirmedNonce set to true makes the Ethereum-like address response include the confirmed nonce, + // which requires an extra eth_getTransactionCount("latest") backend call; off by default to avoid that cost. + WithConfirmedNonce bool `ts_doc:"If true, additionally fetch and return the confirmed nonce for Ethereum-like addresses (extra backend call)."` +} + +// 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 an address and its transactions type Address struct { Paging - AddrStr string `json:"address,omitempty"` - BalanceSat *bchain.Amount `json:"balance"` - TotalReceivedSat *bchain.Amount `json:"totalReceived,omitempty"` - TotalSentSat *bchain.Amount `json:"totalSent,omitempty"` - UnconfirmedBalanceSat *bchain.Amount `json:"unconfirmedBalance"` - UnconfirmedTxs int `json:"unconfirmedTxs"` - Txs int `json:"txs"` - NonTokenTxs int `json:"nonTokenTxs,omitempty"` - Transactions []*Tx `json:"transactions,omitempty"` - Txids []string `json:"txids,omitempty"` - Nonce string `json:"nonce,omitempty"` - UsedTokens int `json:"usedTokens,omitempty"` - UsedAssetTokens int `json:"usedAssetTokens,omitempty"` - Tokens bchain.Tokens `json:"tokens,omitempty"` - TokensAsset bchain.Tokens `json:"tokensAsset,omitempty"` - Erc20Contract *bchain.Erc20Contract `json:"erc20Contract,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,omitempty" ts_doc:"Unconfirmed balance for this address. Omitted for AccountDetailsBasic, where mempool transactions are not aggregated."` + 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 (pending) transaction nonce for Ethereum-like addresses, including mempool transactions. This is the next nonce the account will use."` + ConfirmedNonce string `json:"confirmedNonce,omitempty" ts_doc:"Confirmed transaction nonce for Ethereum-like addresses, reflecting only mined transactions (eth_getTransactionCount at the latest block). Equals nonce when the account has no pending transactions."` + UsedTokens int `json:"usedTokens,omitempty" ts_doc:"Number of tokens with any historical usage at this address."` + // SYSCOIN: SPT token summary fields. + UsedAssetTokens int `json:"usedAssetTokens,omitempty" ts_doc:"Number of Syscoin SPT assets with any historical usage at this address."` + Tokens Tokens `json:"tokens,omitempty" ts_doc:"List of tokens associated with this address."` + TokensAsset Tokens `json:"tokensAsset,omitempty" ts_doc:"List of Syscoin SPT assets 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)."` } -// Asset holds information about asset and its transactions +// Asset holds Syscoin SPT metadata and optional transaction history. +// +// SYSCOIN type Asset struct { Paging - AssetDetails *AssetSpecific `json:"asset"` - UnconfirmedTxs int `json:"unconfirmedTxs,omitempty"` - UnconfirmedBalanceSat *bchain.Amount `json:"unconfirmedBalance,omitempty"` - Txs int `json:"txs"` - Transactions []*Tx `json:"transactions,omitempty"` - Txids []string `json:"txids,omitempty"` - // helpers for explorer - Filter string `json:"-"` -} - -// Asset holds information about searching/filtering assets + AssetDetails *AssetSpecific `json:"asset" ts_doc:"Syscoin SPT metadata."` + UnconfirmedTxs int `json:"unconfirmedTxs,omitempty" ts_doc:"Number of unconfirmed transactions touching this asset."` + UnconfirmedBalanceSat *Amount `json:"unconfirmedBalance,omitempty" ts_doc:"Unconfirmed net asset amount in base units."` + Txs int `json:"txs" ts_doc:"Number of indexed transactions touching this asset."` + Transactions []*Tx `json:"transactions,omitempty" ts_doc:"Transactions touching this asset."` + Txids []string `json:"txids,omitempty" ts_doc:"Transaction IDs touching this asset."` + Filter string `json:"-"` +} + +// Assets holds Syscoin SPT search results. +// +// SYSCOIN type Assets struct { Paging - AssetDetails []*AssetsSpecific `json:"assets"` - NumAssets int `json:"numAssets"` - // helpers for explorer - Filter string `json:"-"` + AssetDetails []*AssetsSpecific `json:"assets" ts_doc:"Matching Syscoin SPT assets."` + NumAssets int `json:"numAssets" ts_doc:"Total number of matching assets."` + Filter string `json:"-"` } // Utxo is one unspent transaction output type Utxo struct { - Txid string `json:"txid"` - Vout int32 `json:"vout"` - AmountSat *bchain.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"` - AssetInfo *AssetInfo `json:"assetInfo,omitempty"` -} - -// Utxos result for API -type Utxos struct { - Utxos []Utxo `json:"utxos"` - Assets []*AssetSpecific `json:"assets,omitempty"` -} - -func (a Utxos) Len() int { return len(a.Utxos) } -func (a Utxos) Swap(i, j int) { a.Utxos[i], a.Utxos[j] = a.Utxos[j], a.Utxos[i] } + 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."` + // SYSCOIN: SPT metadata for this UTXO. + AssetInfo *AssetInfo `json:"assetInfo,omitempty" ts_doc:"Syscoin SPT metadata for this UTXO."` +} + +// Utxos is array of Utxo +type Utxos []Utxo + +func (a Utxos) Len() int { return len(a) } +func (a Utxos) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a Utxos) Less(i, j int) bool { // sort in reverse order, unconfirmed (height==0) utxos on top - hi := a.Utxos[i].Height - hj := a.Utxos[j].Height + hi := a[i].Height + hj := a[j].Height if hi == 0 { hi = maxInt } @@ -291,22 +563,15 @@ func (a Utxos) Less(i, j int) bool { return hi >= hj } -// history of tokens mapped to uint32 asset guid's in BalanceHistory obj -type TokenBalanceHistory struct { - ReceivedSat *bchain.Amount `json:"received,omitempty"` - SentSat *bchain.Amount `json:"sent,omitempty"` -} - // BalanceHistory contains info about one point in time of balance history type BalanceHistory struct { - Time uint32 `json:"time"` - Txs uint32 `json:"txs"` - ReceivedSat *bchain.Amount `json:"received"` - SentSat *bchain.Amount `json:"sent"` - SentToSelfSat *bchain.Amount `json:"sentToSelf"` - FiatRates map[string]float64 `json:"rates,omitempty"` - Txid string `json:"txid,omitempty"` - Tokens map[string]*TokenBalanceHistory `json:"tokens,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 @@ -328,9 +593,9 @@ func (a BalanceHistories) SortAndAggregate(groupByTime uint32) BalanceHistories bhs := make(BalanceHistories, 0) if len(a) > 0 { bha := BalanceHistory{ - SentSat: &bchain.Amount{}, - ReceivedSat: &bchain.Amount{}, - SentToSelfSat: &bchain.Amount{}, + ReceivedSat: &Amount{}, + SentSat: &Amount{}, + SentToSelfSat: &Amount{}, } sort.Sort(a) for i := range a { @@ -344,30 +609,15 @@ func (a BalanceHistories) SortAndAggregate(groupByTime uint32) BalanceHistories } bha = BalanceHistory{ Time: time, - SentSat: &bchain.Amount{}, - ReceivedSat: &bchain.Amount{}, - SentToSelfSat: &bchain.Amount{}, + ReceivedSat: &Amount{}, + SentSat: &Amount{}, + SentToSelfSat: &Amount{}, } } if bha.Txid != bh.Txid { bha.Txs += bh.Txs bha.Txid = bh.Txid } - if len(bh.Tokens) > 0 { - if bha.Tokens == nil { - bha.Tokens = map[string]*TokenBalanceHistory{} - } - // fill up map of balances for each asset guid - for assetGuid, token := range bh.Tokens { - bhaToken, ok := bha.Tokens[assetGuid] - if !ok { - bhaToken = &TokenBalanceHistory{SentSat: &bchain.Amount{}, ReceivedSat: &bchain.Amount{}} - bha.Tokens[assetGuid] = bhaToken - } - (*big.Int)(bhaToken.SentSat).Add((*big.Int)(bhaToken.SentSat), (*big.Int)(token.SentSat)) - (*big.Int)(bhaToken.ReceivedSat).Add((*big.Int)(bhaToken.ReceivedSat), (*big.Int)(token.ReceivedSat)) - } - } (*big.Int)(bha.ReceivedSat).Add((*big.Int)(bha.ReceivedSat), (*big.Int)(bh.ReceivedSat)) (*big.Int)(bha.SentSat).Add((*big.Int)(bha.SentSat), (*big.Int)(bh.SentSat)) (*big.Int)(bha.SentToSelfSat).Add((*big.Int)(bha.SentToSelfSat), (*big.Int)(bh.SentToSelfSat)) @@ -383,76 +633,131 @@ func (a BalanceHistories) SortAndAggregate(groupByTime uint32) BalanceHistories // Blocks is list of blocks with paging information type Blocks struct { Paging - Blocks []bchain.DbBlockInfo `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"` + 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"` - 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" 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" 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" 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 417b20e18c..2dfc8829d8 100644 --- a/api/types_test.go +++ b/api/types_test.go @@ -6,17 +6,16 @@ import ( "encoding/json" "math/big" "reflect" + "sort" "testing" - - "github.com/syscoin/blockbook/bchain" ) func TestAmount_MarshalJSON(t *testing.T) { type amounts struct { - A1 bchain.Amount `json:"a1"` - A2 bchain.Amount `json:"a2,omitempty"` - PA1 *bchain.Amount `json:"pa1"` - PA2 *bchain.Amount `json:"pa2,omitempty"` + A1 Amount `json:"a1"` + A2 Amount `json:"a2,omitempty"` + PA1 *Amount `json:"pa1"` + PA2 *Amount `json:"pa2,omitempty"` } tests := []struct { name string @@ -30,10 +29,10 @@ func TestAmount_MarshalJSON(t *testing.T) { { name: "1", a: amounts{ - A1: (bchain.Amount)(*big.NewInt(123456)), - A2: (bchain.Amount)(*big.NewInt(787901)), - PA1: (*bchain.Amount)(big.NewInt(234567)), - PA2: (*bchain.Amount)(big.NewInt(890123)), + A1: (Amount)(*big.NewInt(123456)), + A2: (Amount)(*big.NewInt(787901)), + PA1: (*Amount)(big.NewInt(234567)), + PA2: (*Amount)(big.NewInt(890123)), }, want: `{"a1":"123456","a2":"787901","pa1":"234567","pa2":"890123"}`, }, @@ -48,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) + } }) } } @@ -69,9 +77,9 @@ func TestBalanceHistories_SortAndAggregate(t *testing.T) { name: "one", a: []BalanceHistory{ { - ReceivedSat: (*bchain.Amount)(big.NewInt(1)), - SentSat: (*bchain.Amount)(big.NewInt(2)), - SentToSelfSat: (*bchain.Amount)(big.NewInt(1)), + ReceivedSat: (*Amount)(big.NewInt(1)), + SentSat: (*Amount)(big.NewInt(2)), + SentToSelfSat: (*Amount)(big.NewInt(1)), Time: 1521514812, Txid: "00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840", Txs: 1, @@ -80,9 +88,9 @@ func TestBalanceHistories_SortAndAggregate(t *testing.T) { groupByTime: 3600, want: []BalanceHistory{ { - ReceivedSat: (*bchain.Amount)(big.NewInt(1)), - SentSat: (*bchain.Amount)(big.NewInt(2)), - SentToSelfSat: (*bchain.Amount)(big.NewInt(1)), + ReceivedSat: (*Amount)(big.NewInt(1)), + SentSat: (*Amount)(big.NewInt(2)), + SentToSelfSat: (*Amount)(big.NewInt(1)), Time: 1521514800, Txs: 1, }, @@ -92,49 +100,49 @@ func TestBalanceHistories_SortAndAggregate(t *testing.T) { name: "aggregate", a: []BalanceHistory{ { - ReceivedSat: (*bchain.Amount)(big.NewInt(1)), - SentSat: (*bchain.Amount)(big.NewInt(2)), - SentToSelfSat: (*bchain.Amount)(big.NewInt(0)), + ReceivedSat: (*Amount)(big.NewInt(1)), + SentSat: (*Amount)(big.NewInt(2)), + SentToSelfSat: (*Amount)(big.NewInt(0)), Time: 1521504812, Txid: "0011223344556677889900112233445566778899001122334455667788990011", Txs: 1, }, { - ReceivedSat: (*bchain.Amount)(big.NewInt(3)), - SentSat: (*bchain.Amount)(big.NewInt(4)), - SentToSelfSat: (*bchain.Amount)(big.NewInt(2)), + ReceivedSat: (*Amount)(big.NewInt(3)), + SentSat: (*Amount)(big.NewInt(4)), + SentToSelfSat: (*Amount)(big.NewInt(2)), Time: 1521504812, Txid: "00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840", Txs: 1, }, { - ReceivedSat: (*bchain.Amount)(big.NewInt(5)), - SentSat: (*bchain.Amount)(big.NewInt(6)), - SentToSelfSat: (*bchain.Amount)(big.NewInt(3)), + ReceivedSat: (*Amount)(big.NewInt(5)), + SentSat: (*Amount)(big.NewInt(6)), + SentToSelfSat: (*Amount)(big.NewInt(3)), Time: 1521514812, Txid: "00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840", Txs: 1, }, { - ReceivedSat: (*bchain.Amount)(big.NewInt(7)), - SentSat: (*bchain.Amount)(big.NewInt(8)), - SentToSelfSat: (*bchain.Amount)(big.NewInt(3)), + ReceivedSat: (*Amount)(big.NewInt(7)), + SentSat: (*Amount)(big.NewInt(8)), + SentToSelfSat: (*Amount)(big.NewInt(3)), Time: 1521504812, Txid: "00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840", Txs: 1, }, { - ReceivedSat: (*bchain.Amount)(big.NewInt(9)), - SentSat: (*bchain.Amount)(big.NewInt(10)), - SentToSelfSat: (*bchain.Amount)(big.NewInt(5)), + ReceivedSat: (*Amount)(big.NewInt(9)), + SentSat: (*Amount)(big.NewInt(10)), + SentToSelfSat: (*Amount)(big.NewInt(5)), Time: 1521534812, Txid: "0011223344556677889900112233445566778899001122334455667788990011", Txs: 1, }, { - ReceivedSat: (*bchain.Amount)(big.NewInt(11)), - SentSat: (*bchain.Amount)(big.NewInt(12)), - SentToSelfSat: (*bchain.Amount)(big.NewInt(6)), + ReceivedSat: (*Amount)(big.NewInt(11)), + SentSat: (*Amount)(big.NewInt(12)), + SentToSelfSat: (*Amount)(big.NewInt(6)), Time: 1521534812, Txid: "1122334455667788990011223344556677889900112233445566778899001100", Txs: 1, @@ -143,23 +151,23 @@ func TestBalanceHistories_SortAndAggregate(t *testing.T) { groupByTime: 3600, want: []BalanceHistory{ { - ReceivedSat: (*bchain.Amount)(big.NewInt(11)), - SentSat: (*bchain.Amount)(big.NewInt(14)), - SentToSelfSat: (*bchain.Amount)(big.NewInt(5)), + ReceivedSat: (*Amount)(big.NewInt(11)), + SentSat: (*Amount)(big.NewInt(14)), + SentToSelfSat: (*Amount)(big.NewInt(5)), Time: 1521504000, Txs: 2, }, { - ReceivedSat: (*bchain.Amount)(big.NewInt(5)), - SentSat: (*bchain.Amount)(big.NewInt(6)), - SentToSelfSat: (*bchain.Amount)(big.NewInt(3)), + ReceivedSat: (*Amount)(big.NewInt(5)), + SentSat: (*Amount)(big.NewInt(6)), + SentToSelfSat: (*Amount)(big.NewInt(3)), Time: 1521514800, Txs: 1, }, { - ReceivedSat: (*bchain.Amount)(big.NewInt(20)), - SentSat: (*bchain.Amount)(big.NewInt(22)), - SentToSelfSat: (*bchain.Amount)(big.NewInt(11)), + ReceivedSat: (*Amount)(big.NewInt(20)), + SentSat: (*Amount)(big.NewInt(22)), + SentToSelfSat: (*Amount)(big.NewInt(11)), Time: 1521532800, Txs: 2, }, @@ -174,3 +182,190 @@ func TestBalanceHistories_SortAndAggregate(t *testing.T) { }) } } + +func TestAmount_Compare(t *testing.T) { + tests := []struct { + name string + a *Amount + b *Amount + want int + }{ + { + name: "nil-nil", + a: nil, + b: nil, + want: 0, + }, + { + name: "20-nil", + a: (*Amount)(big.NewInt(20)), + b: nil, + want: 1, + }, + { + name: "nil-20", + a: nil, + b: (*Amount)(big.NewInt(20)), + want: -1, + }, + { + name: "18-20", + a: (*Amount)(big.NewInt(18)), + b: (*Amount)(big.NewInt(20)), + want: -1, + }, + { + name: "20-20", + a: (*Amount)(big.NewInt(20)), + b: (*Amount)(big.NewInt(20)), + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.a.Compare(tt.b); got != tt.want { + t.Errorf("Amount.Compare() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTokens_Sort(t *testing.T) { + tests := []struct { + name string + unsorted Tokens + sorted Tokens + }{ + { + name: "one", + unsorted: Tokens{ + { + Name: "a", + Contract: "0x1", + BaseValue: 12.34, + }, + }, + sorted: Tokens{ + { + Name: "a", + Contract: "0x1", + BaseValue: 12.34, + }, + }, + }, + { + name: "mix", + unsorted: Tokens{ + { + Name: "", + Contract: "0x6", + BaseValue: 0, + }, + { + Name: "", + Contract: "0x5", + BaseValue: 0, + }, + { + Name: "b", + Contract: "0x2", + BaseValue: 1, + }, + { + Name: "d", + Contract: "0x4", + BaseValue: 0, + }, + { + Name: "a", + Contract: "0x1", + BaseValue: 12.34, + }, + { + Name: "c", + Contract: "0x3", + BaseValue: 0, + }, + }, + sorted: Tokens{ + { + Name: "a", + Contract: "0x1", + BaseValue: 12.34, + }, + { + Name: "b", + Contract: "0x2", + BaseValue: 1, + }, + { + Name: "c", + Contract: "0x3", + BaseValue: 0, + }, + { + Name: "d", + Contract: "0x4", + BaseValue: 0, + }, + { + Name: "", + Contract: "0x5", + BaseValue: 0, + }, + { + Name: "", + Contract: "0x6", + BaseValue: 0, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sort.Sort(tt.unsorted) + if !reflect.DeepEqual(tt.unsorted, tt.sorted) { + t.Errorf("Tokens Sort got %v, want %v", tt.unsorted, tt.sorted) + } + }) + } +} + +// TestTokenDecimals_AlwaysSerialized guards trezor/blockbook#1577: the decimals +// field must always be present on Token and TokenTransfer, including when it is +// 0. Previously the field carried `,omitempty`, so a 0 was dropped entirely and +// clients could not tell a genuine 0-decimal token apart from missing metadata. +func TestTokenDecimals_AlwaysSerialized(t *testing.T) { + tests := []struct { + name string + v interface{} + want string + }{ + { + name: "TokenTransfer zero decimals is present", + v: TokenTransfer{Standard: "ERC20", From: "0xfrom", To: "0xto", Contract: "0xc"}, + want: `{"type":"","standard":"ERC20","from":"0xfrom","to":"0xto","contract":"0xc","decimals":0}`, + }, + { + name: "TokenTransfer non-zero decimals is present", + v: TokenTransfer{Standard: "ERC20", From: "0xfrom", To: "0xto", Contract: "0xc", Decimals: 18}, + want: `{"type":"","standard":"ERC20","from":"0xfrom","to":"0xto","contract":"0xc","decimals":18}`, + }, + { + name: "Token zero decimals is present", + v: Token{Standard: "ERC20", Name: "n", Contract: "0xc"}, + want: `{"type":"","standard":"ERC20","name":"n","contract":"0xc","transfers":0,"decimals":0}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := json.Marshal(tt.v) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if string(b) != tt.want { + t.Errorf("json.Marshal() = %v, want %v", string(b), tt.want) + } + }) + } +} diff --git a/api/typesv1.go b/api/typesv1.go index ff4cfccd65..0ac0867d93 100644 --- a/api/typesv1.go +++ b/api/typesv1.go @@ -3,7 +3,7 @@ package api import ( "math/big" - "github.com/syscoin/blockbook/bchain" + "github.com/trezor/blockbook/bchain" ) // ScriptSigV1 is used for legacy api v1 @@ -178,6 +178,12 @@ func (w *Worker) transactionsToV1(txs []*Tx) []*TxV1 { // AddressToV1 converts Address to AddressV1 func (w *Worker) AddressToV1(a *Address) *AddressV1 { d := w.chainParser.AmountDecimals() + // v1 always serializes UnconfirmedBalance as a decimal string; + // when the v2 field is omitted (AccountDetailsBasic), preserve "0". + unconfirmedBalance := "0" + if a.UnconfirmedBalanceSat != nil { + unconfirmedBalance = a.UnconfirmedBalanceSat.DecimalString(d) + } return &AddressV1{ AddrStr: a.AddrStr, Balance: a.BalanceSat.DecimalString(d), @@ -187,7 +193,7 @@ func (w *Worker) AddressToV1(a *Address) *AddressV1 { Transactions: w.transactionsToV1(a.Transactions), TxApperances: a.Txs, Txids: a.Txids, - UnconfirmedBalance: a.UnconfirmedBalanceSat.DecimalString(d), + UnconfirmedBalance: unconfirmedBalance, UnconfirmedTxApperances: a.UnconfirmedTxs, } } @@ -195,9 +201,9 @@ func (w *Worker) AddressToV1(a *Address) *AddressV1 { // AddressUtxoToV1 converts []AddressUtxo to []AddressUtxoV1 func (w *Worker) AddressUtxoToV1(au Utxos) []AddressUtxoV1 { d := w.chainParser.AmountDecimals() - v1 := make([]AddressUtxoV1, len(au.Utxos)) - for i := range au.Utxos { - utxo := &au.Utxos[i] + v1 := make([]AddressUtxoV1, len(au)) + for i := range au { + utxo := &au[i] v1[i] = AddressUtxoV1{ AmountSat: utxo.AmountSat.AsBigInt(), Amount: utxo.AmountSat.DecimalString(d), diff --git a/api/worker.go b/api/worker.go index ad31586636..600fe2327e 100644 --- a/api/worker.go +++ b/api/worker.go @@ -16,36 +16,51 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/eth" - "github.com/syscoin/blockbook/common" - "github.com/syscoin/blockbook/db" - "github.com/syscoin/syscoinwire/syscoin/wire" + "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/fiat" ) // Worker is handle to api worker type Worker struct { - db *db.RocksDB - txCache *db.TxCache - chain bchain.BlockChain - chainParser bchain.BlockChainParser - chainType bchain.ChainType - mempool bchain.Mempool - is *common.InternalState - metrics *common.Metrics + db *db.RocksDB + txCache *db.TxCache + chain bchain.BlockChain + chainParser bchain.BlockChainParser + chainType bchain.ChainType + 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, - chain: chain, - chainParser: chain.GetChainParser(), - chainType: chain.GetChainParser().GetChainType(), - mempool: mempool, - is: is, - metrics: metrics, + db: db, + txCache: txCache, + chain: chain, + chainParser: chain.GetChainParser(), + chainType: chain.GetChainParser().GetChainType(), + useAddressAliases: chain.GetChainParser().UseAddressAliases(), + mempool: mempool, + is: is, + fiatRates: fiatRates, + metrics: metrics, } if w.chainType == bchain.ChainBitcoinType { w.initXpubCache() @@ -53,6 +68,194 @@ func NewWorker(db *db.RocksDB, chain bchain.BlockChain, mempool bchain.Mempool, return w, nil } +// SYSCOIN: optional parser extension for exposing SPT tx type in API responses. +type syscoinAssetTypeParser interface { + GetAssetTypeFromVersion(nVersion int32) *bchain.TokenType +} + +// SYSCOIN +type syscoinAssetMaskParser interface { + GetAssetsMaskFromVersion(nVersion int32) bchain.AssetsMask +} + +// SYSCOIN +func assetTypeFromVersion(parser bchain.BlockChainParser, version int32) *bchain.TokenType { + p, ok := parser.(syscoinAssetTypeParser) + if !ok { + return nil + } + return p.GetAssetTypeFromVersion(version) +} + +// SYSCOIN +func assetMaskFromVersion(parser bchain.BlockChainParser, version int32) bchain.AssetsMask { + p, ok := parser.(syscoinAssetMaskParser) + if !ok { + return bchain.BaseCoinMask + } + return p.GetAssetsMaskFromVersion(version) +} + +// SYSCOIN: convert indexed SPT metadata to public API fields. +func (w *Worker) assetInfoToAPI(assetInfo *bchain.AssetInfo) *AssetInfo { + if assetInfo == nil { + return nil + } + value := (*Amount)(assetInfo.ValueSat) + valueStr := "" + symbol := "" + if assetInfo.ValueSat != nil { + if dbAsset, err := w.db.GetAsset(assetInfo.AssetGuid, nil); err == nil { + valueStr = value.DecimalString(int(dbAsset.AssetObj.Precision)) + symbol = string(dbAsset.AssetObj.Symbol) + } else { + valueStr = w.chainParser.AmountToDecimalString(assetInfo.ValueSat) + } + } + return &AssetInfo{ + AssetGuid: strconv.FormatUint(assetInfo.AssetGuid, 10), + ValueSat: value, + ValueStr: valueStr, + Symbol: symbol, + } +} + +// SYSCOIN: summarize output-side SPT amounts for the transaction-level Tokens +// section. This preserves the old Syscoin explorer behavior while using the +// current upstream TokenTransfers response field. +func (w *Worker) getSyscoinAssetTransfers(vouts []Vout) []TokenTransfer { + type assetSummary struct { + value *big.Int + symbol string + decimals int + } + summaries := make(map[string]*assetSummary) + for i := range vouts { + assetInfo := vouts[i].AssetInfo + if assetInfo == nil || assetInfo.ValueSat == nil || assetInfo.AssetGuid == "" { + continue + } + summary := summaries[assetInfo.AssetGuid] + if summary == nil { + summary = &assetSummary{ + value: new(big.Int), + symbol: assetInfo.AssetGuid, + decimals: w.chainParser.AmountDecimals(), + } + if guid, err := strconv.ParseUint(assetInfo.AssetGuid, 10, 64); err == nil { + if dbAsset, err := w.db.GetAsset(guid, nil); err == nil { + summary.symbol = string(dbAsset.AssetObj.Symbol) + summary.decimals = int(dbAsset.AssetObj.Precision) + } + } + summaries[assetInfo.AssetGuid] = summary + } + summary.value.Add(summary.value, (*big.Int)(assetInfo.ValueSat)) + } + if len(summaries) == 0 { + return nil + } + assetGuids := make([]string, 0, len(summaries)) + for assetGuid := range summaries { + assetGuids = append(assetGuids, assetGuid) + } + sort.Slice(assetGuids, func(i, j int) bool { + ii, iErr := strconv.ParseUint(assetGuids[i], 10, 64) + jj, jErr := strconv.ParseUint(assetGuids[j], 10, 64) + if iErr == nil && jErr == nil { + return ii < jj + } + return assetGuids[i] < assetGuids[j] + }) + tokens := make([]TokenTransfer, 0, len(assetGuids)) + for _, assetGuid := range assetGuids { + summary := summaries[assetGuid] + value := Amount(*summary.value) + tokens = append(tokens, TokenTransfer{ + Type: bchain.TokenStandardName("SPT"), + Standard: bchain.TokenStandardName("SPT"), + Contract: assetGuid, + Symbol: summary.symbol, + Decimals: summary.decimals, + Value: &value, + AssetGuid: assetGuid, + }) + } + return tokens +} + +// SYSCOIN: build regular address SPT token balances from indexed address +// balances plus unconfirmed per-address asset deltas. +func (w *Worker) getSyscoinAddressAssetTokens(address string, balances map[uint64]*bchain.AssetBalance, mempool map[string]*syscoinTokenMempoolInfo) (Tokens, int, error) { + count := len(balances) + if count == 0 && len(mempool) == 0 { + return nil, 0, nil + } + tokens := make(Tokens, 0, count+len(mempool)) + for guid, balance := range balances { + assetGuid := strconv.FormatUint(guid, 10) + dbAsset, err := w.db.GetAsset(guid, nil) + if err != nil { + dbAsset = &bchain.Asset{} + dbAsset.AssetObj.Symbol = []byte(assetGuid) + dbAsset.AssetObj.Precision = 8 + } + totalReceived := new(big.Int).Add(balance.BalanceSat, balance.SentSat) + var unconfirmed *Amount + unconfirmedTransfers := 0 + if mempoolAsset := mempool[assetGuid]; mempoolAsset != nil { + unconfirmed = (*Amount)(mempoolAsset.valueSat) + unconfirmedTransfers = mempoolAsset.unconfirmedTxs + mempoolAsset.used = true + } + tokens = append(tokens, Token{ + Type: bchain.SPTTokenType, + Standard: bchain.SPTTokenType, + Name: address, + Decimals: int(dbAsset.AssetObj.Precision), + Symbol: string(dbAsset.AssetObj.Symbol), + BalanceSat: (*Amount)(balance.BalanceSat), + UnconfirmedBalanceSat: unconfirmed, + UnconfirmedTransfers: unconfirmedTransfers, + TotalReceivedSat: (*Amount)(totalReceived), + TotalSentSat: (*Amount)(balance.SentSat), + AssetGuid: assetGuid, + Transfers: int(balance.Transfers), + }) + } + for assetGuid, mempoolAsset := range mempool { + if mempoolAsset.used { + continue + } + guid, err := strconv.ParseUint(assetGuid, 10, 64) + if err != nil { + return nil, 0, err + } + dbAsset, err := w.db.GetAsset(guid, nil) + if err != nil { + dbAsset = &bchain.Asset{} + dbAsset.AssetObj.Symbol = []byte(assetGuid) + dbAsset.AssetObj.Precision = 8 + } + zero := Amount(*new(big.Int)) + tokens = append(tokens, Token{ + Type: bchain.SPTTokenType, + Standard: bchain.SPTTokenType, + Name: address, + Decimals: int(dbAsset.AssetObj.Precision), + Symbol: string(dbAsset.AssetObj.Symbol), + BalanceSat: &zero, + UnconfirmedBalanceSat: (*Amount)(mempoolAsset.valueSat), + UnconfirmedTransfers: mempoolAsset.unconfirmedTxs, + TotalReceivedSat: &zero, + TotalSentSat: &zero, + AssetGuid: assetGuid, + }) + } + sort.Sort(tokens) + return tokens, count, nil +} + func (w *Worker) getAddressesFromVout(vout *bchain.Vout) (bchain.AddressDescriptor, []string, bool, error) { addrDesc, err := w.chainParser.GetAddrDescFromVout(vout) if err != nil { @@ -65,7 +268,7 @@ func (w *Worker) getAddressesFromVout(vout *bchain.Vout) (bchain.AddressDescript // setSpendingTxToVout is helper function, that finds transaction that spent given output and sets it to the output // there is no direct index for the operation, it must be found using addresses -> txaddresses -> tx func (w *Worker) setSpendingTxToVout(vout *Vout, txid string, height uint32) error { - err := w.db.GetAddrDescTransactions(vout.AddrDesc, height, maxUint32, bchain.AllMask, func(t string, height uint32, assetGuids []uint64, indexes []int32) error { + err := w.db.GetAddrDescTransactions(vout.AddrDesc, height, maxUint32, func(t string, height uint32, indexes []int32) error { for _, index := range indexes { // take only inputs if index < 0 { @@ -101,8 +304,21 @@ 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) + tx, err := w.getTransaction(txid, false, false, nil) if err != nil { return "", err } @@ -113,12 +329,118 @@ func (w *Worker) GetSpendingTxid(txid string, n int) (string, error) { if err != nil { return "", err } - glog.Info("GetSpendingTxid ", txid, " ", n, ", ", time.Since(start)) + glog.V(1).Info("GetSpendingTxid ", txid, " ", n, ", ", time.Since(start)) return tx.Vout[n].SpentTxID, nil } +func aggregateAddress(m map[string]struct{}, a string) { + if m != nil && len(a) > 0 { + m[a] = struct{}{} + } +} + +func aggregateAddresses(m map[string]struct{}, addresses []string, isAddress bool) { + if m != nil && isAddress { + for _, a := range addresses { + if len(a) > 0 { + m[a] = struct{}{} + } + } + } +} + +func (w *Worker) newAddressesMapForAliases() map[string]struct{} { + // return non nil map only if the chain supports address aliases + if w.useAddressAliases { + return make(map[string]struct{}) + } + // returning nil disables the processing of the address aliases + 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) + var t string + if w.chainType == bchain.ChainEthereumType { + t = "ENS" + } else { + t = "Alias" + } + for a := range addresses { + if w.chainType == bchain.ChainEthereumType { + 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) + if len(n) > 0 { + aliases[a] = AddressAlias{Type: t, Alias: n} + } + } + return aliases + } + return nil +} + // GetTransaction reads transaction data from txid func (w *Worker) GetTransaction(txid string, spendingTxs bool, specificJSON bool) (*Tx, error) { + addresses := w.newAddressesMapForAliases() + tx, err := w.getTransaction(txid, spendingTxs, specificJSON, addresses) + if err != nil { + return nil, err + } + tx.AddressAliases = w.getAddressAliases(addresses) + 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) if err != nil { if err == bchain.ErrTxNotFound { @@ -126,15 +448,74 @@ 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) + return w.GetTransactionFromBchainTx(bchainTx, height, spendingTxs, specificJSON, addresses) +} + +func (w *Worker) getParsedEthereumInputData(data string) *bchain.EthereumParsedInputData { + var err error + var signatures *[]bchain.FourByteSignature + fourBytes := eth.GetSignatureFromData(data) + if fourBytes != 0 { + signatures, err = w.db.GetFourByteSignatures(fourBytes) + if err != nil { + glog.Errorf("GetFourByteSignatures(%v) error %v", fourBytes, err) + return nil + } + if signatures == nil { + return nil + } + } + return w.chainParser.ParseInputData(signatures, data) +} + +// getConfirmationETA returns confirmation ETA in seconds and blocks +func (w *Worker) getConfirmationETA(tx *Tx) (int64, uint32) { + var etaBlocks uint32 + var etaSeconds int64 + if w.chainType == bchain.ChainBitcoinType && tx.FeesSat != nil { + _, _, mempoolSize := w.is.GetMempoolSyncState() + // if there are a few transactions in the mempool, the estimate fee does not work well + // and the tx is most probably going to be confirmed in the first block + if mempoolSize < 32 { + etaBlocks = 1 + } else { + var txFeePerKB int64 + if tx.VSize > 0 { + txFeePerKB = 1000 * tx.FeesSat.AsInt64() / int64(tx.VSize) + } else if tx.Size > 0 { + txFeePerKB = 1000 * tx.FeesSat.AsInt64() / int64(tx.Size) + } + if txFeePerKB > 0 { + // binary search the estimate, split it to more common first 7 blocks and the rest up to 70 blocks + var b int + fee, _ := w.cachedEstimateFee(7, true) + if fee.Int64() <= txFeePerKB { + b = sort.Search(7, func(i int) bool { + // fee is in sats/kB + fee, _ := w.cachedEstimateFee(i+1, true) + return fee.Int64() <= txFeePerKB + }) + b += 1 + } else { + b = sort.Search(63, func(i int) bool { + fee, _ := w.cachedEstimateFee(i+7, true) + return fee.Int64() <= txFeePerKB + }) + b += 7 + } + etaBlocks = uint32(b) + } + } + etaSeconds = int64(etaBlocks * w.is.AvgBlockPeriod) + } + return etaSeconds, etaBlocks } // GetTransactionFromBchainTx reads transaction data from txid -func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spendingTxs bool, specificJSON bool) (*Tx, error) { +func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spendingTxs bool, specificJSON bool, addresses map[string]struct{}) (*Tx, error) { var err error - var ta *bchain.TxAddresses - var tokens []*bchain.TokenTransferSummary - var mapTTS map[uint64]*bchain.TokenTransferSummary + var ta *db.TxAddresses + var tokens []TokenTransfer var ethSpecific *EthereumSpecific var blockhash string if bchainTx.Confirmations > 0 { @@ -152,7 +533,6 @@ func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe var valInSat, valOutSat, feesSat big.Int var pValInSat *big.Int vins := make([]Vin, len(bchainTx.Vin)) - txVersionAsset := w.chainParser.GetAssetTypeFromVersion(bchainTx.Version) rbf := false for i := range bchainTx.Vin { bchainVin := &bchainTx.Vin[i] @@ -167,9 +547,6 @@ func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe } vin.Hex = bchainVin.ScriptSig.Hex vin.Coinbase = bchainVin.Coinbase - if bchainVin.AssetInfo != nil { - vin.AssetInfo = &AssetInfo{AssetGuid: strconv.FormatUint(bchainVin.AssetInfo.AssetGuid, 10), ValueSat: (*bchain.Amount)(bchainVin.AssetInfo.ValueSat)} - } if w.chainType == bchain.ChainBitcoinType { // bchainVin.Txid=="" is coinbase transaction if bchainVin.Txid != "" { @@ -178,7 +555,6 @@ func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe if err != nil { return nil, errors.Annotatef(err, "GetTxAddresses %v", bchainVin.Txid) } - assetGuid := uint64(0) if tas == nil { // try to load from backend otx, _, err := w.txCache.GetTransaction(bchainVin.Txid) @@ -190,6 +566,7 @@ func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe if err != nil { glog.Warning("GetAddressesFromAddrDesc tx ", bchainVin.Txid, ", addrDesc ", vin.AddrDesc, ": ", err) } + aggregateAddresses(addresses, vin.Addresses, vin.IsAddress) continue } return nil, errors.Annotatef(err, "txCache.GetTransaction %v", bchainVin.Txid) @@ -201,53 +578,31 @@ func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe } if len(otx.Vout) > int(vin.Vout) { vout := &otx.Vout[vin.Vout] - vin.ValueSat = (*bchain.Amount)(&vout.ValueSat) + vin.ValueSat = (*Amount)(&vout.ValueSat) + // SYSCOIN + vin.AssetInfo = w.assetInfoToAPI(vout.AssetInfo) vin.AddrDesc, vin.Addresses, vin.IsAddress, err = w.getAddressesFromVout(vout) if err != nil { glog.Errorf("getAddressesFromVout error %v, vout %+v", err, vout) } - if vout.AssetInfo != nil { - assetGuid = vout.AssetInfo.AssetGuid - vin.AssetInfo = &AssetInfo{AssetGuid: strconv.FormatUint(vout.AssetInfo.AssetGuid, 10), ValueSat: (*bchain.Amount)(vout.AssetInfo.ValueSat)} - } + aggregateAddresses(addresses, vin.Addresses, vin.IsAddress) } } else { if len(tas.Outputs) > int(vin.Vout) { output := &tas.Outputs[vin.Vout] - vin.ValueSat = (*bchain.Amount)(&output.ValueSat) + vin.ValueSat = (*Amount)(&output.ValueSat) + // SYSCOIN + vin.AssetInfo = w.assetInfoToAPI(output.AssetInfo) vin.AddrDesc = output.AddrDesc vin.Addresses, vin.IsAddress, err = output.Addresses(w.chainParser) if err != nil { glog.Errorf("output.Addresses error %v, tx %v, output %v", err, bchainVin.Txid, i) } - if output.AssetInfo != nil { - assetGuid = output.AssetInfo.AssetGuid - vin.AssetInfo = &AssetInfo{AssetGuid: strconv.FormatUint(output.AssetInfo.AssetGuid, 10), ValueSat: (*bchain.Amount)(output.AssetInfo.ValueSat)} - } + aggregateAddresses(addresses, vin.Addresses, vin.IsAddress) } } if vin.ValueSat != nil { valInSat.Add(&valInSat, (*big.Int)(vin.ValueSat)) - if vin.AssetInfo != nil { - if mapTTS == nil { - mapTTS = map[uint64]*bchain.TokenTransferSummary{} - } - tts, ok := mapTTS[assetGuid] - if !ok { - dbAsset, errAsset := w.db.GetAsset(assetGuid, nil) - if errAsset != nil { - return nil, errAsset - } - tts = &bchain.TokenTransferSummary{ - Token: vin.AssetInfo.AssetGuid, - Decimals: int(dbAsset.AssetObj.Precision), - Value: (*bchain.Amount)(big.NewInt(0)), - Symbol: string(dbAsset.AssetObj.Symbol), - } - mapTTS[assetGuid] = tts - } - vin.AssetInfo.ValueStr = vin.AssetInfo.ValueSat.DecimalString(tts.Decimals) + " " + tts.Symbol - } } } } else if w.chainType == bchain.ChainEthereumType { @@ -258,6 +613,7 @@ func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe } vin.Addresses = bchainVin.Addresses vin.IsAddress = true + aggregateAddresses(addresses, vin.Addresses, vin.IsAddress) } } } @@ -266,44 +622,28 @@ func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe bchainVout := &bchainTx.Vout[i] vout := &vouts[i] vout.N = i - vout.ValueSat = (*bchain.Amount)(&bchainVout.ValueSat) + vout.ValueSat = (*Amount)(&bchainVout.ValueSat) + // SYSCOIN + vout.AssetInfo = w.assetInfoToAPI(bchainVout.AssetInfo) valOutSat.Add(&valOutSat, &bchainVout.ValueSat) - if bchainVout.AssetInfo != nil { - vout.AssetInfo = &AssetInfo{AssetGuid: strconv.FormatUint(bchainVout.AssetInfo.AssetGuid, 10), ValueSat: (*bchain.Amount)(bchainVout.AssetInfo.ValueSat)} - } vout.Hex = bchainVout.ScriptPubKey.Hex vout.AddrDesc, vout.Addresses, vout.IsAddress, err = w.getAddressesFromVout(bchainVout) if err != nil { glog.V(2).Infof("getAddressesFromVout error %v, %v, output %v", err, bchainTx.Txid, bchainVout.N) } - if vout.AssetInfo != nil { - if mapTTS == nil { - mapTTS = map[uint64]*bchain.TokenTransferSummary{} - } - tts, ok := mapTTS[bchainVout.AssetInfo.AssetGuid] - if !ok { - dbAsset, errAsset := w.db.GetAsset(bchainVout.AssetInfo.AssetGuid, nil) - if errAsset != nil { - return nil, errAsset - } - - tts = &bchain.TokenTransferSummary{ - Token: vout.AssetInfo.AssetGuid, - Decimals: int(dbAsset.AssetObj.Precision), - Value: (*bchain.Amount)(big.NewInt(0)), - Symbol: string(dbAsset.AssetObj.Symbol), - } - mapTTS[bchainVout.AssetInfo.AssetGuid] = tts - } - vout.AssetInfo.ValueStr = vout.AssetInfo.ValueSat.DecimalString(tts.Decimals) + " " + tts.Symbol - (*big.Int)(tts.Value).Add((*big.Int)(tts.Value), (*big.Int)(vout.AssetInfo.ValueSat)) - } + 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) + } } } } @@ -315,40 +655,71 @@ func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe feesSat.SetUint64(0) } pValInSat = &valInSat - // flatten TTS Map - if mapTTS != nil && len(mapTTS) > 0 { - tokens = make([]*bchain.TokenTransferSummary, 0, len(mapTTS)) - for _, token := range mapTTS { - tokens = append(tokens, token) - } - } - + tokens = w.getSyscoinAssetTransfers(vouts) // SYSCOIN } else if w.chainType == bchain.ChainEthereumType { - ets, err := w.chainParser.EthereumTypeGetErc20FromTx(bchainTx) + tokenTransfers, err := w.chainParser.EthereumTypeGetTokenTransfersFromTx(bchainTx) if err != nil { - glog.Errorf("GetErc20FromTx error %v, %v", err, bchainTx) + glog.Errorf("GetTokenTransfersFromTx error %v, %v", err, bchainTx) + } + tokens = w.getEthereumTokensTransfers(tokenTransfers, addresses) + ethTxData := w.chainParser.GetEthereumTxData(bchainTx) + + var internalData *bchain.EthereumInternalData + if bchain.ProcessInternalTransactions { + internalData, err = w.db.GetEthereumInternalData(bchainTx.Txid) + if err != nil { + return nil, err + } } - tokens = w.getTokensFromErc20(ets) - ethTxData := eth.GetEthereumTxData(bchainTx) + + parsedInputData := w.getParsedEthereumInputData(ethTxData.Data) + // 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: (*bchain.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, + 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 + ethSpecific.CreatedContract = internalData.Contract + ethSpecific.Error = internalData.Error + ethSpecific.InternalTransfers = make([]EthereumInternalTransfer, len(internalData.Transfers)) + for i := range internalData.Transfers { + f := &internalData.Transfers[i] + t := ðSpecific.InternalTransfers[i] + t.From = f.From + aggregateAddress(addresses, t.From) + t.To = f.To + aggregateAddress(addresses, t.To) + t.Type = f.Type + t.Value = (*Amount)(&f.Value) + } } + } - // for now do not return size, we would have to compute vsize of segwit transactions - // size:=len(bchainTx.Hex) / 2 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) @@ -356,32 +727,38 @@ func (w *Worker) GetTransactionFromBchainTx(bchainTx *bchain.Tx, height int, spe return nil, err } } - // for mempool transaction get first seen time - if bchainTx.Confirmations == 0 { - bchainTx.Blocktime = int64(w.mempool.GetTransactionTime(bchainTx.Txid)) + chainExtraData, err = w.getTxChainExtraData(bchainTx) + if err != nil { + glog.Warningf("GetTxChainExtraData error %v, %v", err, bchainTx) } r := &Tx{ - Blockhash: blockhash, - Blockheight: height, - Blocktime: bchainTx.Blocktime, - Confirmations: bchainTx.Confirmations, - FeesSat: (*bchain.Amount)(&feesSat), - Locktime: bchainTx.LockTime, - Txid: bchainTx.Txid, - ValueInSat: (*bchain.Amount)(pValInSat), - ValueOutSat: (*bchain.Amount)(&valOutSat), - Version: bchainTx.Version, - Hex: bchainTx.Hex, - Rbf: rbf, - Vin: vins, - Vout: vouts, - CoinSpecificData: sj, - TokenTransferSummary: tokens, - TokenType: txVersionAsset, - EthereumSpecific: ethSpecific, - } - if ta != nil && len(ta.Memo) > 0 { - r.Memo = ta.Memo + Blockhash: blockhash, + Blockheight: height, + Blocktime: bchainTx.Blocktime, + Confirmations: bchainTx.Confirmations, + FeesSat: (*Amount)(&feesSat), + Locktime: bchainTx.LockTime, + Txid: bchainTx.Txid, + ValueInSat: (*Amount)(pValInSat), + ValueOutSat: (*Amount)(&valOutSat), + Version: bchainTx.Version, + Size: len(bchainTx.Hex) >> 1, + VSize: int(bchainTx.VSize), + Hex: bchainTx.Hex, + Rbf: rbf, + Vin: vins, + Vout: vouts, + CoinSpecificData: sj, + ChainExtraData: chainExtraData, + TokenTransfers: tokens, + // SYSCOIN + TokenType: assetTypeFromVersion(w.chainParser, bchainTx.Version), + Memo: bchainTx.Memo, + EthereumSpecific: ethSpecific, + } + if bchainTx.Confirmations == 0 { + r.Blocktime = int64(w.mempool.GetTransactionTime(bchainTx.Txid)) + r.ConfirmationETASeconds, r.ConfirmationETABlocks = w.getConfirmationETA(r) } return r, nil } @@ -392,12 +769,12 @@ func (w *Worker) GetTransactionFromMempoolTx(mempoolTx *bchain.MempoolTx) (*Tx, var err error var valInSat, valOutSat, feesSat big.Int var pValInSat *big.Int - var tokens []*bchain.TokenTransferSummary - var mapTTS map[uint64]*bchain.TokenTransferSummary + var tokens []TokenTransfer var ethSpecific *EthereumSpecific + var chainExtraData *TxChainExtraData + addresses := w.newAddressesMapForAliases() vins := make([]Vin, len(mempoolTx.Vin)) rbf := false - txVersionAsset := w.chainParser.GetAssetTypeFromVersion(mempoolTx.Version) for i := range mempoolTx.Vin { bchainVin := &mempoolTx.Vin[i] vin := &vins[i] @@ -411,39 +788,17 @@ func (w *Worker) GetTransactionFromMempoolTx(mempoolTx *bchain.MempoolTx) (*Tx, } vin.Hex = bchainVin.ScriptSig.Hex vin.Coinbase = bchainVin.Coinbase - if bchainVin.AssetInfo != nil { - vin.AssetInfo = &AssetInfo{AssetGuid: strconv.FormatUint(bchainVin.AssetInfo.AssetGuid, 10), ValueSat: (*bchain.Amount)(bchainVin.AssetInfo.ValueSat)} - } if w.chainType == bchain.ChainBitcoinType { // bchainVin.Txid=="" is coinbase transaction if bchainVin.Txid != "" { - vin.ValueSat = (*bchain.Amount)(&bchainVin.ValueSat) + vin.ValueSat = (*Amount)(&bchainVin.ValueSat) vin.AddrDesc = bchainVin.AddrDesc + vin.AssetInfo = w.assetInfoToAPI(bchainVin.AssetInfo) // SYSCOIN vin.Addresses, vin.IsAddress, _ = w.chainParser.GetAddressesFromAddrDesc(vin.AddrDesc) if vin.ValueSat != nil { valInSat.Add(&valInSat, (*big.Int)(vin.ValueSat)) } - if vin.AssetInfo != nil { - if mapTTS == nil { - mapTTS = map[uint64]*bchain.TokenTransferSummary{} - } - tts, ok := mapTTS[bchainVin.AssetInfo.AssetGuid] - if !ok { - dbAsset, errAsset := w.db.GetAsset(bchainVin.AssetInfo.AssetGuid, nil) - if errAsset != nil { - return nil, errAsset - } - - tts = &bchain.TokenTransferSummary{ - Token: vin.AssetInfo.AssetGuid, - Decimals: int(dbAsset.AssetObj.Precision), - Value: (*bchain.Amount)(big.NewInt(0)), - Symbol: string(dbAsset.AssetObj.Symbol), - } - mapTTS[bchainVin.AssetInfo.AssetGuid] = tts - } - vin.AssetInfo.ValueStr = vin.AssetInfo.ValueSat.DecimalString(tts.Decimals) + " " + tts.Symbol - } + aggregateAddresses(addresses, vin.Addresses, vin.IsAddress) } } else if w.chainType == bchain.ChainEthereumType { if len(bchainVin.Addresses) > 0 { @@ -453,6 +808,7 @@ func (w *Worker) GetTransactionFromMempoolTx(mempoolTx *bchain.MempoolTx) (*Tx, } vin.Addresses = bchainVin.Addresses vin.IsAddress = true + aggregateAddresses(addresses, vin.Addresses, vin.IsAddress) } } } @@ -461,38 +817,16 @@ func (w *Worker) GetTransactionFromMempoolTx(mempoolTx *bchain.MempoolTx) (*Tx, bchainVout := &mempoolTx.Vout[i] vout := &vouts[i] vout.N = i - vout.ValueSat = (*bchain.Amount)(&bchainVout.ValueSat) + vout.ValueSat = (*Amount)(&bchainVout.ValueSat) + // SYSCOIN + vout.AssetInfo = w.assetInfoToAPI(bchainVout.AssetInfo) valOutSat.Add(&valOutSat, &bchainVout.ValueSat) vout.Hex = bchainVout.ScriptPubKey.Hex vout.AddrDesc, vout.Addresses, vout.IsAddress, err = w.getAddressesFromVout(bchainVout) if err != nil { glog.V(2).Infof("getAddressesFromVout error %v, %v, output %v", err, mempoolTx.Txid, bchainVout.N) } - if bchainVout.AssetInfo != nil { - vout.AssetInfo = &AssetInfo{AssetGuid: strconv.FormatUint(bchainVout.AssetInfo.AssetGuid, 10), ValueSat: (*bchain.Amount)(bchainVout.AssetInfo.ValueSat)} - } - if vout.AssetInfo != nil { - if mapTTS == nil { - mapTTS = map[uint64]*bchain.TokenTransferSummary{} - } - tts, ok := mapTTS[bchainVout.AssetInfo.AssetGuid] - if !ok { - dbAsset, errAsset := w.db.GetAsset(bchainVout.AssetInfo.AssetGuid, nil) - if errAsset != nil { - return nil, errAsset - } - - tts = &bchain.TokenTransferSummary{ - Token: vout.AssetInfo.AssetGuid, - Decimals: int(dbAsset.AssetObj.Precision), - Value: (*bchain.Amount)(big.NewInt(0)), - Symbol: string(dbAsset.AssetObj.Symbol), - } - mapTTS[bchainVout.AssetInfo.AssetGuid] = tts - } - vout.AssetInfo.ValueStr = vout.AssetInfo.ValueSat.DecimalString(tts.Decimals) + " " + tts.Symbol - (*big.Int)(tts.Value).Add((*big.Int)(tts.Value), (*big.Int)(vout.AssetInfo.ValueSat)) - } + aggregateAddresses(addresses, vout.Addresses, vout.IsAddress) } if w.chainType == bchain.ChainBitcoinType { // for coinbase transactions valIn is 0 @@ -501,109 +835,255 @@ func (w *Worker) GetTransactionFromMempoolTx(mempoolTx *bchain.MempoolTx) (*Tx, feesSat.SetUint64(0) } pValInSat = &valInSat - // flatten TTS Map - if mapTTS != nil && len(mapTTS) > 0 { - tokens = make([]*bchain.TokenTransferSummary, 0, len(mapTTS)) - for _, token := range mapTTS { - tokens = append(tokens, token) - } - } + tokens = w.getSyscoinAssetTransfers(vouts) // SYSCOIN } else if w.chainType == bchain.ChainEthereumType { if len(mempoolTx.Vout) > 0 { valOutSat = mempoolTx.Vout[0].ValueSat } - tokens = w.getTokensFromErc20(mempoolTx.Erc20) - ethTxData := eth.GetEthereumTxDataFromSpecificData(mempoolTx.CoinSpecificData) + tokens = w.getEthereumTokensTransfers(mempoolTx.TokenTransfers, addresses) + ethTxData := w.chainParser.GetEthereumTxData(&bchain.Tx{ + Txid: mempoolTx.Txid, + CoinSpecificData: mempoolTx.CoinSpecificData, + }) ethSpecific = &EthereumSpecific{ - GasLimit: ethTxData.GasLimit, - GasPrice: (*bchain.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, - FeesSat: (*bchain.Amount)(&feesSat), - Locktime: mempoolTx.LockTime, - Txid: mempoolTx.Txid, - ValueInSat: (*bchain.Amount)(pValInSat), - ValueOutSat: (*bchain.Amount)(&valOutSat), - Version: mempoolTx.Version, - Hex: mempoolTx.Hex, - Rbf: rbf, - Vin: vins, - Vout: vouts, - TokenTransferSummary: tokens, - TokenType: txVersionAsset, - EthereumSpecific: ethSpecific, - } + Blocktime: mempoolTx.Blocktime, + FeesSat: (*Amount)(&feesSat), + Locktime: mempoolTx.LockTime, + Txid: mempoolTx.Txid, + ValueInSat: (*Amount)(pValInSat), + ValueOutSat: (*Amount)(&valOutSat), + Version: mempoolTx.Version, + Size: len(mempoolTx.Hex) >> 1, + VSize: int(mempoolTx.VSize), + Hex: mempoolTx.Hex, + Rbf: rbf, + Vin: vins, + Vout: vouts, + ChainExtraData: chainExtraData, + TokenTransfers: tokens, + // SYSCOIN + TokenType: assetTypeFromVersion(w.chainParser, mempoolTx.Version), + EthereumSpecific: ethSpecific, + AddressAliases: w.getAddressAliases(addresses), + } + r.ConfirmationETASeconds, r.ConfirmationETABlocks = w.getConfirmationETA(r) return r, nil } -func (w *Worker) getTokensFromErc20(erc20 []bchain.Erc20Transfer) []*bchain.TokenTransferSummary { - tokens := make([]*bchain.TokenTransferSummary, len(erc20)) - for i := range erc20 { - e := &erc20[i] - cd, err := w.chainParser.GetAddrDescFromAddress(e.Contract) - if err != nil { - glog.Errorf("GetAddrDescFromAddress error %v, contract %v", err, e.Contract) - continue +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, standardFromContext) +} + +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, 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 bchain.ProcessInternalTransactions { + glog.Warningf("Contract %v %v not found in DB", cd, standardFromContext) } - erc20c, err := w.chain.EthereumTypeGetErc20ContractInfo(cd) + contractInfo, err = w.chain.GetContractInfo(cd) if err != nil { - glog.Errorf("GetErc20ContractInfo error %v, contract %v", err, e.Contract) + glog.Errorf("GetContractInfo from chain error %v, contract %v", err, cd) + } + if contractInfo == nil { + contractInfo = &bchain.ContractInfo{Standard: bchain.UnknownTokenStandard, Decimals: w.chainParser.AmountDecimals()} + addresses, _, _ := w.chainParser.GetAddressesFromAddrDesc(cd) + if len(addresses) > 0 { + contractInfo.Contract = addresses[0] + } + + validContract = false + } else { + 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) + } } - if erc20c == nil { - erc20c = &bchain.Erc20Contract{Name: e.Contract} + } 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 { + glog.Errorf("GetContractInfo from chain error %v, contract %v", err, cd) + } else { + if blockchainContractInfo != nil && len(blockchainContractInfo.Name) > 0 && blockchainContractInfo.Name[0] != 0 { + contractInfo.Name = blockchainContractInfo.Name + } else { + contractInfo.Name = "" + } + if blockchainContractInfo != nil && len(blockchainContractInfo.Symbol) > 0 && blockchainContractInfo.Symbol[0] != 0 { + contractInfo.Symbol = blockchainContractInfo.Symbol + } else { + contractInfo.Symbol = "" + } + if blockchainContractInfo != nil { + contractInfo.Decimals = blockchainContractInfo.Decimals + } else if contractInfo.Decimals == 0 && contractInfo.Standard == bchain.UnhandledTokenStandard { + // contract metadata could not be read on-chain; fall back to the coin's + // default decimals (18 for ERC-20) instead of persisting an ambiguous 0 + // for a token whose true precision is simply unknown (trezor/blockbook#1577) + contractInfo.Decimals = w.chainParser.AmountDecimals() + } + 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) + } } - tokens[i] = &bchain.TokenTransferSummary{ - Token: e.Contract, - From: e.From, - To: e.To, - Decimals: erc20c.Decimals, - Value: (*bchain.Amount)(&e.Tokens), - Name: erc20c.Name, - Symbol: erc20c.Symbol, + } + // never surface an unresolved contract (still Unhandled because its metadata + // could not be fetched, e.g. a transient RPC error above) with a bare 0 + // decimals; use the coin default instead. Genuinely 0-decimal tokens always + // carry a resolved (handled) standard, so they are unaffected (trezor/blockbook#1577) + if contractInfo.Decimals == 0 && contractInfo.Standard == bchain.UnhandledTokenStandard { + contractInfo.Decimals = w.chainParser.AmountDecimals() + } + return contractInfo, validContract, nil +} + +func (w *Worker) getEthereumTokensTransfers(transfers bchain.TokenTransfers, addresses map[string]struct{}) []TokenTransfer { + tokens := make([]TokenTransfer, len(transfers)) + 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, + } } } return tokens } +func (w *Worker) GetEthereumTokenURI(contract string, id string) (string, *bchain.ContractInfo, error) { + cd, err := w.chainParser.GetAddrDescFromAddress(contract) + if err != nil { + return "", nil, err + } + tokenId, ok := new(big.Int).SetString(id, 10) + if !ok { + return "", nil, errors.New("Invalid token id") + } + uri, err := w.chain.GetTokenURI(cd, tokenId) + if err != nil { + return "", nil, err + } + ci, _, err := w.getContractDescriptorInfo(cd, bchain.UnknownTokenStandard) + if err != nil { + return "", nil, err + } + return uri, ci, nil +} + func (w *Worker) getAddressTxids(addrDesc bchain.AddressDescriptor, mempool bool, filter *AddressFilter, maxResults int) ([]string, error) { var err error txids := make([]string, 0, 4) - contract := uint64(0) - if len(filter.Contract) > 0 { - contract, err = strconv.ParseUint(filter.Contract, 10, 64) - if err != nil { - return nil, err + txMatchesAssetMask := func(txid string) (bool, error) { + if filter.AssetsMask == bchain.AllMask { + return true, nil + } + ta, err := w.db.GetTxAddresses(txid) + if err != nil || ta == nil { + return false, err } + mask := assetMaskFromVersion(w.chainParser, ta.Version) + if mask == bchain.AllMask { + return true, nil + } + return uint32(filter.AssetsMask)&uint32(mask) == uint32(mask), nil } var callback db.GetTransactionsCallback if filter.Vout == AddressFilterVoutOff { - callback = func(txid string, height uint32, assetGuids []uint64, indexes []int32) error { - if contract > 0 { - for _, assetGuid := range assetGuids { - if contract == assetGuid { - txids = append(txids, txid) - if len(txids) >= maxResults { - return &db.StopIteration{} - } - break - } - } - } else { - txids = append(txids, txid) - if len(txids) >= maxResults { - return &db.StopIteration{} + callback = func(txid string, height uint32, indexes []int32) error { + if !mempool { + matches, err := txMatchesAssetMask(txid) // SYSCOIN + if err != nil || !matches { + return err } } + txids = append(txids, txid) + if len(txids) >= maxResults { + return &db.StopIteration{} + } return nil } } else { - callback = func(txid string, height uint32, assetGuids []uint64, indexes []int32) error { + callback = func(txid string, height uint32, indexes []int32) error { + if !mempool { + matches, err := txMatchesAssetMask(txid) // SYSCOIN + if err != nil || !matches { + return err + } + } for _, index := range indexes { vout := index if vout < 0 { @@ -619,17 +1099,6 @@ func (w *Worker) getAddressTxids(addrDesc bchain.AddressDescriptor, mempool bool break } } - if contract > 0 { - for _, assetGuid := range assetGuids { - if contract == assetGuid { - txids = append(txids, txid) - if len(txids) >= maxResults { - return &db.StopIteration{} - } - break - } - } - } return nil } } @@ -641,8 +1110,24 @@ func (w *Worker) getAddressTxids(addrDesc bchain.AddressDescriptor, mempool bool } for _, m := range o { if _, found := uniqueTxs[m.Txid]; !found { + if filter.AssetsMask != bchain.AllMask { + tx, err := w.getTransaction(m.Txid, false, true, nil) + if err != nil || tx == nil { + glog.Warning("GetTransaction in mempool assetMask filter: ", err) + continue + } + mask := assetMaskFromVersion(w.chainParser, tx.Version) + if mask != bchain.AllMask && uint32(filter.AssetsMask)&uint32(mask) != uint32(mask) { + continue + } + } l := len(txids) - callback(m.Txid, 0, []uint64{0}, []int32{m.Vout}) + if err := callback(m.Txid, 0, []int32{m.Vout}); err != nil { + if _, ok := err.(*db.StopIteration); ok { + return txids, nil + } + return nil, err + } if len(txids) > l { uniqueTxs[m.Txid] = struct{}{} } @@ -653,18 +1138,25 @@ func (w *Worker) getAddressTxids(addrDesc bchain.AddressDescriptor, mempool bool if to == 0 { to = maxUint32 } - err = w.db.GetAddrDescTransactions(addrDesc, filter.FromHeight, to, filter.AssetsMask, callback) + err = w.db.GetAddrDescTransactions(addrDesc, filter.FromHeight, to, callback) if err != nil { return nil, err } } return txids, nil } + +// SYSCOIN: getAssetTxids returns confirmed or mempool transaction ids touching +// one SPT asset, preserving Blockbook's newest-first history ordering. func (w *Worker) getAssetTxids(assetGuid uint64, mempool bool, filter *AddressFilter, maxResults int) ([]string, error) { - var err error + if filter == nil { + filter = &AddressFilter{} + } txids := make([]string, 0, 4) - var callback db.GetTxAssetsCallback - callback = func(txidsIn []string) error { + if maxResults <= 0 { + return txids, nil + } + callback := func(txidsIn []string) error { txids = append(txids, txidsIn...) if len(txids) >= maxResults { return &db.StopIteration{} @@ -673,51 +1165,364 @@ func (w *Worker) getAssetTxids(assetGuid uint64, mempool bool, filter *AddressFi } if mempool { uniqueTxs := make(map[string]struct{}) - o := w.mempool.GetTxAssets(assetGuid) - for _, m := range o { - if _, found := uniqueTxs[m.Txid]; !found { - l := len(txids) - callback([]string{m.Txid}) - if len(txids) > l { - uniqueTxs[m.Txid] = struct{}{} + for _, entry := range w.mempool.GetTxAssets(assetGuid) { + if _, found := uniqueTxs[entry.Txid]; found { + continue + } + if filter.AssetsMask != bchain.AllMask { + tx, err := w.getTransaction(entry.Txid, false, true, nil) + if err != nil || tx == nil { + glog.Warning("GetTransaction in asset mempool filter: ", err) + continue + } + mask := assetMaskFromVersion(w.chainParser, tx.Version) + if mask != bchain.AllMask && uint32(filter.AssetsMask)&uint32(mask) != uint32(mask) { + continue + } + } + l := len(txids) + if err := callback([]string{entry.Txid}); err != nil { + if _, ok := err.(*db.StopIteration); ok { + break } + return nil, err + } + if len(txids) > l { + uniqueTxs[entry.Txid] = struct{}{} } } - } else { - to := filter.ToHeight - if to == 0 { - to = maxUint32 + return txids, nil + } + to := filter.ToHeight + if to == 0 { + to = maxUint32 + } + if err := w.db.GetTxAssets(assetGuid, filter.FromHeight, to, filter.AssetsMask, callback); err != nil { + return nil, err + } + return txids, nil +} + +// SYSCOIN: assetMempoolValue returns the unconfirmed delta for one asset in a +// transaction. Outputs add to the delta and inputs subtract from it. +func (t *Tx) assetMempoolValue(assetGuid string) *big.Int { + var val big.Int + for i := range t.Vout { + assetInfo := t.Vout[i].AssetInfo + if assetInfo != nil && assetInfo.AssetGuid == assetGuid && assetInfo.ValueSat != nil { + val.Add(&val, (*big.Int)(assetInfo.ValueSat)) + } + } + for i := range t.Vin { + assetInfo := t.Vin[i].AssetInfo + if assetInfo != nil && assetInfo.AssetGuid == assetGuid && assetInfo.ValueSat != nil { + val.Sub(&val, (*big.Int)(assetInfo.ValueSat)) } - err = w.db.GetTxAssets(assetGuid, filter.FromHeight, to, filter.AssetsMask, callback) + } + return &val +} + +// SYSCOIN: FindAssetsFromFilter searches the local SPT metadata cache by symbol +// or NEVM contract. Callers page the result with FindAssets. +func (w *Worker) FindAssetsFromFilter(filter string) []*AssetsSpecific { + start := time.Now() + if w.db.GetSetupAssetCacheFirstTime() { + if err := w.db.SetupAssetCache(); err != nil { + glog.Error("FindAssetsFromFilter SetupAssetCache ", err) + return nil + } + w.db.SetSetupAssetCacheFirstTime(false) + } + filterLower := strings.ToLower(strings.ReplaceAll(filter, "0x", "")) + assetDetails := make([]*AssetsSpecific, 0) + for guid, assetCached := range *w.db.GetAssetCache() { + symbol := string(assetCached.AssetObj.Symbol) + symbolLower := strings.ToLower(symbol) + foundAsset := strings.Contains(symbolLower, filterLower) + contract := "" + if len(assetCached.AssetObj.Contract) > 0 { + contract = ethcommon.BytesToAddress(assetCached.AssetObj.Contract).Hex() + if len(filterLower) > 5 && strings.Contains(strings.ToLower(contract), filterLower) { + foundAsset = true + } + } + if !foundAsset { + continue + } + txs := int(assetCached.Transactions) + assetDetails = append(assetDetails, &AssetsSpecific{ + AssetGuid: strconv.FormatUint(guid, 10), + Symbol: symbol, + Contract: contract, + TotalSupply: (*Amount)(big.NewInt(assetCached.AssetObj.TotalSupply)), + Decimals: int(assetCached.AssetObj.Precision), + Txs: txs, + MetaData: string(assetCached.MetaData), + }) + } + sort.Slice(assetDetails, func(i, j int) bool { + ii, iErr := strconv.ParseUint(assetDetails[i].AssetGuid, 10, 64) + jj, jErr := strconv.ParseUint(assetDetails[j].AssetGuid, 10, 64) + if iErr == nil && jErr == nil { + return ii < jj + } + return assetDetails[i].AssetGuid < assetDetails[j].AssetGuid + }) + glog.Info("FindAssetsFromFilter, ", time.Since(start)) + return assetDetails +} + +// FindAssets returns paged Syscoin SPT search results. +// +// SYSCOIN +func (w *Worker) FindAssets(filter string, page int, txsOnPage int) *Assets { + page-- + if page < 0 { + page = 0 + } + start := time.Now() + assetsFiltered := w.FindAssetsFromFilter(filter) + var from, to int + var pg Paging + pg, from, to, page = computePaging(len(assetsFiltered), page, txsOnPage) + assetDetails := make([]*AssetsSpecific, to-from) + for i := from; i < to; i++ { + assetDetails[i-from] = assetsFiltered[i] + } + r := &Assets{ + AssetDetails: assetDetails, + Paging: pg, + NumAssets: len(assetsFiltered), + Filter: filter, + } + glog.Info("FindAssets filter: ", filter, ", ", time.Since(start)) + return r +} + +// GetSPVProof returns Syscoin bridge SPV proof for txid. +// +// SYSCOIN +func (w *Worker) GetSPVProof(hash string) (json.RawMessage, error) { + return w.chain.GetSPVProof(hash) +} + +// GetAsset gets metadata and optional transactions for a Syscoin SPT asset. +// +// SYSCOIN +func (w *Worker) GetAsset(asset string, page int, txsOnPage int, option AccountDetails, filter *AddressFilter) (*Asset, error) { + start := time.Now() + page-- + if page < 0 { + page = 0 + } + if filter == nil { + filter = &AddressFilter{} + } + assetGuid, err := strconv.ParseUint(asset, 10, 64) + if err != nil { + return nil, err + } + dbAsset, err := w.db.GetAsset(assetGuid, nil) + if err != nil { + return nil, NewAPIError("Asset not found", true) + } + totalResults := int(dbAsset.Transactions) + if filter.FromHeight != 0 || filter.ToHeight != 0 || filter.AssetsMask != bchain.AllMask { + totalResults = -1 + } + var txs []*Tx + var txids []string + var mempoolTxs []*Tx + var mempoolTxids []string + var unconfirmedTxs int + var uBalSat big.Int + if filter.ToHeight == 0 && !filter.OnlyConfirmed { + txm, err := w.getAssetTxids(assetGuid, true, filter, maxInt) if err != nil { - return nil, err + return nil, errors.Annotatef(err, "getAssetTxids %v true", asset) + } + for _, txid := range txm { + tx, err := w.GetTransaction(txid, false, false) + if err != nil || tx == nil { + glog.Warning("GetTransaction in mempool: ", err) + continue + } + if tx.Confirmations != 0 { + continue + } + uBalSat.Add(&uBalSat, tx.assetMempoolValue(asset)) + unconfirmedTxs++ + if option == AccountDetailsTxidHistory { + mempoolTxids = append(mempoolTxids, tx.Txid) + } else if option >= AccountDetailsTxHistoryLight { + mempoolTxs = append(mempoolTxs, tx) + } } } - return txids, nil + var pg Paging + if option >= AccountDetailsTxidHistory { + mempoolItems := unconfirmedTxs + if totalResults >= 0 { + _, _, _, page = computePaging(totalResults+mempoolItems, page, txsOnPage) + } + virtualFrom := page * txsOnPage + virtualTo := (page + 1) * txsOnPage + confirmedFrom := virtualFrom - mempoolItems + if confirmedFrom < 0 { + confirmedFrom = 0 + } + confirmedTo := virtualTo - mempoolItems + if confirmedTo < 0 { + confirmedTo = 0 + } + txc, err := w.getAssetTxids(assetGuid, false, filter, confirmedTo) + if err != nil { + return nil, errors.Annotatef(err, "getAssetTxids %v false", asset) + } + bestheight, _, err := w.db.GetBestBlock() + if err != nil { + return nil, errors.Annotatef(err, "GetBestBlock") + } + pg, _, _, page = computePaging(len(txc)+mempoolItems, page, txsOnPage) + if totalResults >= 0 { + pg, _, _, _ = computePaging(totalResults+mempoolItems, page, txsOnPage) + } else if confirmedTo > 0 && len(txc) >= confirmedTo { + pg.TotalPages = -1 + } + mempoolFrom := virtualFrom + if mempoolFrom > mempoolItems { + mempoolFrom = mempoolItems + } + mempoolTo := virtualTo + if mempoolTo > mempoolItems { + mempoolTo = mempoolItems + } + if option == AccountDetailsTxidHistory { + txids = append(txids, mempoolTxids[mempoolFrom:mempoolTo]...) + } else { + txs = append(txs, mempoolTxs[mempoolFrom:mempoolTo]...) + } + if confirmedTo > len(txc) { + confirmedTo = len(txc) + } + for i := confirmedFrom; i < confirmedTo; i++ { + txid := txc[i] + if option == AccountDetailsTxidHistory { + txids = append(txids, txid) + } else { + tx, err := w.txFromTxid(txid, bestheight, option, nil, nil) + if err != nil { + return nil, err + } + txs = append(txs, tx) + } + } + } + // 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] + } + } + contract := "" + if len(dbAsset.AssetObj.Contract) > 0 { + contract = ethcommon.BytesToAddress(dbAsset.AssetObj.Contract).Hex() + } + r := &Asset{ + AssetDetails: &AssetSpecific{ + AssetGuid: asset, + Symbol: string(dbAsset.AssetObj.Symbol), + Contract: contract, + TotalSupply: (*Amount)(big.NewInt(dbAsset.AssetObj.TotalSupply)), + MaxSupply: (*Amount)(big.NewInt(dbAsset.AssetObj.MaxSupply)), + Decimals: int(dbAsset.AssetObj.Precision), + MetaData: string(dbAsset.MetaData), + }, + Paging: pg, + UnconfirmedBalanceSat: (*Amount)(&uBalSat), + UnconfirmedTxs: unconfirmedTxs, + Transactions: txs, + Txs: int(dbAsset.Transactions), + Txids: txids, + } + glog.Info("GetAsset ", asset, ", ", time.Since(start)) + return r, nil } -func (t *Tx) getAddrVoutValue(addrDesc bchain.AddressDescriptor, mapAssetMempool map[string]*TokenMempoolInfo) *big.Int { + +func (t *Tx) getAddrVoutValue(addrDesc bchain.AddressDescriptor) *big.Int { var val big.Int - var foundAsset bool = false for _, vout := range t.Vout { if bytes.Equal(vout.AddrDesc, addrDesc) && vout.ValueSat != nil { val.Add(&val, (*big.Int)(vout.ValueSat)) - if vout.AssetInfo != nil { - mempoolAsset, ok := mapAssetMempool[vout.AssetInfo.AssetGuid] - if !ok { - mempoolAsset = &TokenMempoolInfo{Used: false, UnconfirmedTxs: 0, ValueSat: &big.Int{}} - mapAssetMempool[vout.AssetInfo.AssetGuid] = mempoolAsset - } - mempoolAsset.ValueSat.Add(mempoolAsset.ValueSat, (*big.Int)(vout.AssetInfo.ValueSat)) - // count tx only once - if !foundAsset { - mempoolAsset.UnconfirmedTxs++ - } - foundAsset = true - } } } return &val } + +type syscoinTokenMempoolInfo struct { + used bool + unconfirmedTxs int + valueSat *big.Int + txids map[string]struct{} +} + +// SYSCOIN +func (t *Tx) addAddrVoutAssetMempool(addrDesc bchain.AddressDescriptor, mempool map[string]*syscoinTokenMempoolInfo) { + foundAssets := make(map[string]struct{}) + for i := range t.Vout { + vout := &t.Vout[i] + if !bytes.Equal(vout.AddrDesc, addrDesc) || vout.AssetInfo == nil || vout.AssetInfo.ValueSat == nil { + continue + } + asset := mempool[vout.AssetInfo.AssetGuid] + if asset == nil { + asset = &syscoinTokenMempoolInfo{valueSat: new(big.Int)} + mempool[vout.AssetInfo.AssetGuid] = asset + } + asset.valueSat.Add(asset.valueSat, (*big.Int)(vout.AssetInfo.ValueSat)) + if _, found := foundAssets[vout.AssetInfo.AssetGuid]; !found { + asset.addUnconfirmedTx(t.Txid) + foundAssets[vout.AssetInfo.AssetGuid] = struct{}{} + } + } +} + +// SYSCOIN +func (t *Tx) addAddrVinAssetMempool(addrDesc bchain.AddressDescriptor, mempool map[string]*syscoinTokenMempoolInfo) { + foundAssets := make(map[string]struct{}) + for i := range t.Vin { + vin := &t.Vin[i] + if !bytes.Equal(vin.AddrDesc, addrDesc) || vin.AssetInfo == nil || vin.AssetInfo.ValueSat == nil { + continue + } + asset := mempool[vin.AssetInfo.AssetGuid] + if asset == nil { + asset = &syscoinTokenMempoolInfo{valueSat: new(big.Int)} + mempool[vin.AssetInfo.AssetGuid] = asset + } + asset.valueSat.Sub(asset.valueSat, (*big.Int)(vin.AssetInfo.ValueSat)) + if _, found := foundAssets[vin.AssetInfo.AssetGuid]; !found { + asset.addUnconfirmedTx(t.Txid) + foundAssets[vin.AssetInfo.AssetGuid] = struct{}{} + } + } +} + +// SYSCOIN +func (i *syscoinTokenMempoolInfo) addUnconfirmedTx(txid string) { + if i.txids == nil { + i.txids = make(map[string]struct{}) + } + if _, found := i.txids[txid]; found { + return + } + i.txids[txid] = struct{}{} + i.unconfirmedTxs++ +} + func (t *Tx) getAddrEthereumTypeMempoolInputValue(addrDesc bchain.AddressDescriptor) *big.Int { var val big.Int if len(t.Vin) > 0 && len(t.Vout) > 0 && bytes.Equal(t.Vin[0].AddrDesc, addrDesc) { @@ -732,30 +1537,11 @@ func (t *Tx) getAddrEthereumTypeMempoolInputValue(addrDesc bchain.AddressDescrip return &val } -func (t *Tx) getAssetOutValue(assetGuid string) *big.Int { - var val big.Int - for _, vout := range t.Vout { - if vout.AssetInfo != nil && vout.AssetInfo.AssetGuid == assetGuid { - val.Add(&val, (*big.Int)(vout.AssetInfo.ValueSat)) - } - } - return &val -} - -func (t *Tx) getAddrVinValue(addrDesc bchain.AddressDescriptor, mapAssetMempool map[string]*TokenMempoolInfo) *big.Int { +func (t *Tx) getAddrVinValue(addrDesc bchain.AddressDescriptor) *big.Int { var val big.Int for _, vin := range t.Vin { if bytes.Equal(vin.AddrDesc, addrDesc) && vin.ValueSat != nil { val.Add(&val, (*big.Int)(vin.ValueSat)) - if vin.AssetInfo != nil { - mempoolAsset, ok := mapAssetMempool[vin.AssetInfo.AssetGuid] - if !ok { - // ensure we can reflect negative unconfirmed amounts for send-only transactions - mempoolAsset = &TokenMempoolInfo{Used: false, UnconfirmedTxs: 0, ValueSat: &big.Int{}} - mapAssetMempool[vin.AssetInfo.AssetGuid] = mempoolAsset - } - mempoolAsset.ValueSat.Sub(mempoolAsset.ValueSat, (*big.Int)(vin.AssetInfo.ValueSat)) - } } } return &val @@ -777,90 +1563,46 @@ func GetUniqueTxids(txids []string) []string { return ut[0:i] } -func (w *Worker) txFromTxAddress(txid string, ta *bchain.TxAddresses, bi *bchain.DbBlockInfo, bestheight uint32) *Tx { +func (w *Worker) txFromTxAddress(txid string, ta *db.TxAddresses, bi *db.BlockInfo, bestheight uint32, addresses map[string]struct{}) *Tx { var err error var valInSat, valOutSat, feesSat big.Int - var tokens []*bchain.TokenTransferSummary - var mapTTS map[uint64]*bchain.TokenTransferSummary vins := make([]Vin, len(ta.Inputs)) - txVersionAsset := w.chainParser.GetAssetTypeFromVersion(ta.Version) for i := range ta.Inputs { tai := &ta.Inputs[i] vin := &vins[i] vin.N = i - vin.ValueSat = (*bchain.Amount)(&tai.ValueSat) + vin.ValueSat = (*Amount)(&tai.ValueSat) valInSat.Add(&valInSat, &tai.ValueSat) + vin.AssetInfo = w.assetInfoToAPI(tai.AssetInfo) // SYSCOIN vin.Addresses, vin.IsAddress, err = tai.Addresses(w.chainParser) if err != nil { glog.Errorf("tai.Addresses error %v, tx %v, input %v, tai %+v", err, txid, i, tai) } - if tai.AssetInfo != nil { - vin.AssetInfo = &AssetInfo{AssetGuid: strconv.FormatUint(tai.AssetInfo.AssetGuid, 10), ValueSat: (*bchain.Amount)(tai.AssetInfo.ValueSat)} - } - if vin.AssetInfo != nil { - if mapTTS == nil { - mapTTS = map[uint64]*bchain.TokenTransferSummary{} - } - tts, ok := mapTTS[tai.AssetInfo.AssetGuid] - if !ok { - dbAsset, errAsset := w.db.GetAsset(tai.AssetInfo.AssetGuid, nil) - if errAsset != nil { - dbAsset = &bchain.Asset{Transactions: 0, AssetObj: wire.AssetType{Symbol: []byte(strconv.FormatUint(tai.AssetInfo.AssetGuid, 10)), Precision: 8}} - } - tts = &bchain.TokenTransferSummary{ - Token: vin.AssetInfo.AssetGuid, - Decimals: int(dbAsset.AssetObj.Precision), - Value: (*bchain.Amount)(big.NewInt(0)), - Symbol: string(dbAsset.AssetObj.Symbol), - } - mapTTS[tai.AssetInfo.AssetGuid] = tts - } - vin.AssetInfo.ValueStr = vin.AssetInfo.ValueSat.DecimalString(tts.Decimals) + " " + tts.Symbol + if w.db.HasExtendedIndex() { + vin.Txid = tai.Txid + vin.Vout = tai.Vout } + aggregateAddresses(addresses, vin.Addresses, vin.IsAddress) } vouts := make([]Vout, len(ta.Outputs)) for i := range ta.Outputs { tao := &ta.Outputs[i] vout := &vouts[i] vout.N = i - vout.ValueSat = (*bchain.Amount)(&tao.ValueSat) + vout.ValueSat = (*Amount)(&tao.ValueSat) valOutSat.Add(&valOutSat, &tao.ValueSat) + vout.AssetInfo = w.assetInfoToAPI(tao.AssetInfo) // SYSCOIN vout.Addresses, vout.IsAddress, err = tao.Addresses(w.chainParser) if err != nil { glog.Errorf("tai.Addresses error %v, tx %v, output %v, tao %+v", err, txid, i, tao) } vout.Spent = tao.Spent - if tao.AssetInfo != nil { - vout.AssetInfo = &AssetInfo{AssetGuid: strconv.FormatUint(tao.AssetInfo.AssetGuid, 10), ValueSat: (*bchain.Amount)(tao.AssetInfo.ValueSat)} - } - if vout.AssetInfo != nil { - if mapTTS == nil { - mapTTS = map[uint64]*bchain.TokenTransferSummary{} - } - tts, ok := mapTTS[tao.AssetInfo.AssetGuid] - if !ok { - dbAsset, errAsset := w.db.GetAsset(tao.AssetInfo.AssetGuid, nil) - if errAsset != nil { - dbAsset = &bchain.Asset{Transactions: 0, AssetObj: wire.AssetType{Symbol: []byte(vout.AssetInfo.AssetGuid), Precision: 8}} - } - tts = &bchain.TokenTransferSummary{ - Token: vout.AssetInfo.AssetGuid, - Decimals: int(dbAsset.AssetObj.Precision), - Value: (*bchain.Amount)(big.NewInt(0)), - Symbol: string(dbAsset.AssetObj.Symbol), - } - mapTTS[tao.AssetInfo.AssetGuid] = tts - } - vout.AssetInfo.ValueStr = vout.AssetInfo.ValueSat.DecimalString(tts.Decimals) + " " + tts.Symbol - (*big.Int)(tts.Value).Add((*big.Int)(tts.Value), (*big.Int)(vout.AssetInfo.ValueSat)) - } - } - // flatten TTS Map - if mapTTS != nil && len(mapTTS) > 0 { - tokens = make([]*bchain.TokenTransferSummary, 0, len(mapTTS)) - for _, token := range mapTTS { - tokens = append(tokens, token) + 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 feesSat.Sub(&valInSat, &valOutSat) @@ -868,39 +1610,68 @@ func (w *Worker) txFromTxAddress(txid string, ta *bchain.TxAddresses, bi *bchain feesSat.SetUint64(0) } r := &Tx{ - Blockhash: bi.Hash, - Blockheight: int(ta.Height), - Blocktime: bi.Time, - Confirmations: bestheight - ta.Height + 1, - FeesSat: (*bchain.Amount)(&feesSat), - Txid: txid, - ValueInSat: (*bchain.Amount)(&valInSat), - ValueOutSat: (*bchain.Amount)(&valOutSat), - Vin: vins, - Vout: vouts, - TokenTransferSummary: tokens, - TokenType: txVersionAsset, - } - if len(ta.Memo) > 0 { - r.Memo = ta.Memo + Blockhash: bi.Hash, + Blockheight: int(ta.Height), + Blocktime: bi.Time, + Confirmations: bestheight - ta.Height + 1, + FeesSat: (*Amount)(&feesSat), + Txid: txid, + ValueInSat: (*Amount)(&valInSat), + ValueOutSat: (*Amount)(&valOutSat), + Vin: vins, + Vout: vouts, + TokenTransfers: w.getSyscoinAssetTransfers(vouts), // SYSCOIN + TokenType: assetTypeFromVersion(w.chainParser, ta.Version), // SYSCOIN + Memo: ta.Memo, // SYSCOIN + } + 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, @@ -908,21 +1679,97 @@ func computePaging(count, page, itemsOnPage int) (Paging, int, int, int) { }, from, to, page } -func (w *Worker) getEthereumToken(index int, addrDesc, contract bchain.AddressDescriptor, details AccountDetails, txs uint32) (*bchain.Token, error) { - var b *big.Int - validContract := true - ci, err := w.chain.EthereumTypeGetErc20ContractInfo(contract) +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, "EthereumTypeGetErc20ContractInfo %v", contract) + return nil, errors.Annotatef(err, "getEthereumContractBalance %v", c.Contract) + } + t := Token{ + Contract: ci.Contract, + Name: ci.Name, + Symbol: ci.Symbol, + Type: standard, + Standard: standard, + Transfers: int(c.Txs), + Decimals: ci.Decimals, + ContractIndex: strconv.Itoa(index), } - if ci == nil { - ci = &bchain.Erc20Contract{} - addresses, _, _ := w.chainParser.GetAddressesFromAddrDesc(contract) - if len(addresses) > 0 { - ci.Contract = addresses[0] - ci.Name = addresses[0] + // return contract balances/values only at or above AccountDetailsTokenBalances + if details >= AccountDetailsTokenBalances && validContract { + if c.Standard == bchain.FungibleToken { + // get Erc20 Contract Balance from blockchain, balance obtained from adding and subtracting transfers is not correct + // 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) + if found { + value, err := strconv.ParseFloat(t.BalanceSat.DecimalString(t.Decimals), 64) + if err == nil { + t.BaseValue = value * baseRate + if ticker != nil { + secondaryRate, found := ticker.Rates[secondaryCoin] + if found { + t.SecondaryValue = t.BaseValue * float64(secondaryRate) + } + } + } + } + } + } + } else { + if len(c.Ids) > 0 { + ids := make([]Amount, len(c.Ids)) + for j := range ids { + ids[j] = (Amount)(c.Ids[j]) + } + t.Ids = ids + } + if len(c.MultiTokenValues) > 0 { + idValues := make([]MultiTokenValue, len(c.MultiTokenValues)) + for j := range idValues { + idValues[j].Id = (*Amount)(&c.MultiTokenValues[j].Id) + idValues[j].Value = (*Amount)(&c.MultiTokenValues[j].Value) + } + t.MultiTokenValues = idValues + } } - validContract = false + } + + 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.UnknownTokenStandard) + if err != nil { + return nil, errors.Annotatef(err, "GetContractInfo %v", contract) } // do not read contract balances etc in case of Basic option if details >= AccountDetailsTokenBalances && validContract { @@ -934,125 +1781,270 @@ func (w *Worker) getEthereumToken(index int, addrDesc, contract bchain.AddressDe } else { b = nil } - return &bchain.Token{ - Type: bchain.ERC20TokenType, - BalanceSat: (*bchain.Amount)(b), + return &Token{ + Type: ci.Standard, + Standard: ci.Standard, + BalanceSat: (*Amount)(b), Contract: ci.Contract, Name: ci.Name, Symbol: ci.Symbol, - Transfers: txs, + Transfers: 0, Decimals: ci.Decimals, - ContractIndex: strconv.Itoa(index), + ContractIndex: "0", }, nil } -func (w *Worker) getEthereumTypeAddressBalances(addrDesc bchain.AddressDescriptor, details AccountDetails, filter *AddressFilter) (*bchain.AddrBalance, bchain.Tokens, *bchain.Erc20Contract, uint64, int, int, error) { - var ( - ba *bchain.AddrBalance - tokens bchain.Tokens - ci *bchain.Erc20Contract - n uint64 - nonContractTxs int - ) - // unknown number of results for paging - totalResults := -1 +// 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(token) + if !found { + if timestamp == 0 { + ticker = w.fiatRates.GetCurrentTicker("", token) + } else { + tickers, err := w.fiatRates.GetTickersForTimestamps([]int64{timestamp}, "", token) + if err != nil || tickers == nil || len(*tickers) == 0 { + ticker = nil + } else { + ticker = (*tickers)[0] + } + } + if ticker == nil { + return 0, false + } + rate, found = ticker.GetTokenRate(token) + } + + return float64(rate), found +} + +type ethereumTypeAddressData struct { + tokens Tokens + contractInfo *bchain.ContractInfo + nonce string + confirmedNonce string + nonContractTxs int + internalTxs int + 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) { + var ba *db.AddrBalance + var nPending, nConfirmed uint64 + var confirmedNonceOK bool + // 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, nil, 0, 0, 0, NewAPIError(fmt.Sprintf("Address not found, %v", err), true) + 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, nil, 0, 0, 0, errors.Annotatef(err, "EthereumTypeGetBalance %v", addrDesc) + 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, nil, 0, 0, 0, NewAPIError(fmt.Sprintf("Invalid contract filter, %v", err), true) + return nil, nil, NewAPIError(fmt.Sprintf("Invalid contract filter, %v", err), true) } } if ca != nil { - ba = &bchain.AddrBalance{ + // Address has indexed contract/tx data; include totals and nonce. + ba = &db.AddrBalance{ Txs: uint32(ca.TotalTxs), } if b != nil { ba.BalanceSat = *b } - n, err = w.chain.EthereumTypeGetNonce(addrDesc) + nPending, nConfirmed, confirmedNonceOK, err = w.chain.EthereumTypeGetNonces(addrDesc, filter.WithConfirmedNonce) if err != nil { - return nil, nil, nil, 0, 0, 0, errors.Annotatef(err, "EthereumTypeGetNonce %v", addrDesc) + return nil, nil, errors.Annotatef(err, "EthereumTypeGetNonces %v", addrDesc) + } + 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 { - tokens = make(bchain.Tokens, len(ca.Contracts)) + d.tokens = make([]Token, len(ca.Contracts)) var j int - for i, c := range ca.Contracts { + for i := range ca.Contracts { + c := &ca.Contracts[i] if len(filterDesc) > 0 { if !bytes.Equal(filterDesc, c.Contract) { continue } // filter only transactions of this contract - filter.Vout = i + 1 + filter.Vout = i + db.ContractIndexOffset } - t, err := w.getEthereumToken(i+1, addrDesc, c.Contract, details, uint32(c.Txs)) - if err != nil { - return nil, nil, nil, 0, 0, 0, err + // 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)] } - tokens[j] = t - j++ - } - // special handling if filter has contract - // if the address has no transactions with given contract, check the balance, the address may have some balance even without transactions - if len(filterDesc) > 0 && j == 0 && details >= AccountDetailsTokens { - t, err := w.getEthereumToken(0, addrDesc, filterDesc, details, 0) + t, err := w.getEthereumContractBalance(addrDesc, i+db.ContractIndexOffset, c, details, ticker, secondaryCoin, erc20Balance, erc20Batched) if err != nil { - return nil, nil, nil, 0, 0, 0, err + return nil, nil, err } - tokens = bchain.Tokens{t} - // switch off query for transactions, there are no transactions - filter.Vout = AddressFilterVoutQueryNotNecessary - } else { - tokens = tokens[:j] + // 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 + j++ } + d.tokens = d.tokens[:j] + sort.Sort(d.tokens) + w.enrichTokenProtocols(d.tokens, filter.Protocols) } - ci, err = w.chain.EthereumTypeGetErc20ContractInfo(addrDesc) + d.contractInfo, err = w.db.GetContractInfo(addrDesc, bchain.UnknownTokenStandard) if err != nil { - return nil, nil, nil, 0, 0, 0, err + 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 { - totalResults = int(ca.TotalTxs) + d.totalResults = int(ca.TotalTxs) } else if filter.Vout == 0 { - totalResults = int(ca.NonContractTxs) - } else if filter.Vout > 0 && filter.Vout-1 < len(ca.Contracts) { - totalResults = int(ca.Contracts[filter.Vout-1].Txs) + d.totalResults = int(ca.NonContractTxs) + } else if filter.Vout == db.InternalTxIndexOffset { + d.totalResults = int(ca.InternalTxs) + } else if filter.Vout >= db.ContractIndexOffset && filter.Vout-db.ContractIndexOffset < len(ca.Contracts) { + d.totalResults = int(ca.Contracts[filter.Vout-db.ContractIndexOffset].Txs) } else if filter.Vout == AddressFilterVoutQueryNotNecessary { - totalResults = 0 + d.totalResults = 0 } } - nonContractTxs = int(ca.NonContractTxs) + d.nonContractTxs = int(ca.NonContractTxs) + d.internalTxs = int(ca.InternalTxs) } else { - // addresses without any normal transactions can have internal transactions and therefore balance + // addresses without any normal transactions can have internal transactions that were not processed and therefore balance if b != nil { - ba = &bchain.AddrBalance{ + ba = &db.AddrBalance{ BalanceSat: *b, } } - // special handling if filtering for a contract, check the ballance of it - if len(filterDesc) > 0 && details >= AccountDetailsTokens { - t, err := w.getEthereumToken(0, addrDesc, filterDesc, details, 0) + // A fresh address has no indexed data only because it has never sent a transaction (every + // sender is recorded in the index), so both its pending and confirmed nonce are 0 - the same + // value the indexed path would fetch, without a backend call. When the caller opted in, + // surface the confirmed nonce as "0" for symmetry with the indexed path; omitting it would be + // indistinguishable from the feature not being deployed. nPending/nConfirmed are already 0. + confirmedNonceOK = filter.WithConfirmedNonce + } + // returns 0 for unknown address + d.nonce = strconv.Itoa(int(nPending)) + // confirmed nonce is gated and best-effort: surfaced only when the caller opted in + // and the backend lookup succeeded; otherwise it is left empty and omitted + if confirmedNonceOK { + d.confirmedNonce = strconv.Itoa(int(nConfirmed)) + } + // 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 + } + d.tokens = []Token{*t} + // switch off query for transactions, there are no transactions + 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, nil, 0, 0, 0, err + return nil, nil, err } - tokens = bchain.Tokens{t} - // switch off query for transactions, there are no transactions - filter.Vout = AddressFilterVoutQueryNotNecessary } } - return ba, tokens, ci, n, nonContractTxs, totalResults, nil + 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 *bchain.DbBlockInfo) (*Tx, error) { +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 // only ChainBitcoinType supports TxHistoryLight @@ -1064,9 +2056,9 @@ func (w *Worker) txFromTxid(txid string, bestheight uint32, option AccountDetail if ta == nil { glog.Warning("DB inconsistency: tx ", txid, ": not found in txAddresses") // as fallback, get tx from backend - tx, err = w.GetTransaction(txid, false, false) + tx, err = w.getTransaction(txid, false, false, addresses) if err != nil { - return nil, errors.Annotatef(err, "GetTransaction %v", txid) + return nil, errors.Annotatef(err, "getTransaction %v", txid) } } else { if blockInfo == nil { @@ -1077,18 +2069,15 @@ func (w *Worker) txFromTxid(txid string, bestheight uint32, option AccountDetail if blockInfo == nil { glog.Warning("DB inconsistency: block height ", ta.Height, ": not found in db") // provide empty BlockInfo to return the rest of tx data - blockInfo = &bchain.DbBlockInfo{} + blockInfo = &db.BlockInfo{} } } - tx = w.txFromTxAddress(txid, ta, blockInfo, bestheight) - if tx == nil { - return nil, NewAPIError(fmt.Sprintf("GetTransaction, %v", txid), true) - } + tx = w.txFromTxAddress(txid, ta, blockInfo, bestHeight, addresses) } } else { - tx, err = w.GetTransaction(txid, false, false) + tx, err = w.getTransaction(txid, false, false, addresses) if err != nil { - return nil, errors.Annotatef(err, "GetTransaction %v", txid) + return nil, errors.Annotatef(err, "getTransaction %v", txid) } } return tx, nil @@ -1138,47 +2127,82 @@ 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) (*Address, error) { +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 { page = 0 } var ( - ba *bchain.AddrBalance - tokens bchain.Tokens - erc20c *bchain.Erc20Contract + ba *db.AddrBalance txm []string txs []*Tx txids []string + accountChainExtraData *AccountChainExtraData pg Paging uBalSat big.Int + uBalSending big.Int + uBalReceiving big.Int totalReceived, totalSent *big.Int - nonce string unconfirmedTxs int - nonTokenTxs int totalResults int ) + ed := ðereumTypeAddressData{} addrDesc, address, err := w.getAddrDescAndNormalizeAddress(address) 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 { - var n uint64 - ba, tokens, erc20c, n, nonTokenTxs, totalResults, err = w.getEthereumTypeAddressBalances(addrDesc, option, filter) + ba, ed, err = w.getEthereumTypeAddressBalances(addrDesc, option, filter, secondaryCoin) if err != nil { return nil, err } - nonce = strconv.Itoa(int(n)) + totalResults = ed.totalResults } else { // ba can be nil if the address is only in mempool! - ba, err = w.db.GetAddrDescBalance(addrDesc, bchain.AddressBalanceDetailNoUTXO) + ba, err = w.db.GetAddrDescBalance(addrDesc, db.AddressBalanceDetailNoUTXO) if err != nil { return nil, NewAPIError(fmt.Sprintf("Address not found, %v", err), true) } if ba != nil { // totalResults is known only if there is no filter - if filter.Vout == AddressFilterVoutOff && filter.FromHeight == 0 && filter.ToHeight == 0 { + if filter.Vout == AddressFilterVoutOff && filter.FromHeight == 0 && filter.ToHeight == 0 && filter.AssetsMask == bchain.AllMask { totalResults = int(ba.Txs) } else { totalResults = -1 @@ -1187,37 +2211,49 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco } // if there are only unconfirmed transactions, there is no paging if ba == nil { - ba = &bchain.AddrBalance{} + ba = &db.AddrBalance{} page = 0 } - mapAssetMempool := map[string]*TokenMempoolInfo{} + addresses := w.newAddressesMapForAliases() + assetMempool := make(map[string]*syscoinTokenMempoolInfo) // SYSCOIN // process mempool, only if toHeight is not specified if filter.ToHeight == 0 && !filter.OnlyConfirmed { txm, err = w.getAddressTxids(addrDesc, true, filter, maxInt) if err != nil { return nil, errors.Annotatef(err, "getAddressTxids %v true", addrDesc) } - for _, txid := range txm { - tx, err := w.GetTransaction(txid, false, true) - // mempool transaction may fail - if err != nil || tx == nil { - glog.Warning("GetTransaction in mempool: ", err) - } else { - // skip already confirmed txs, mempool may be out of sync - if tx.Confirmations == 0 { - unconfirmedTxs++ - uBalSat.Add(&uBalSat, tx.getAddrVoutValue(addrDesc, mapAssetMempool)) - // 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)) - } else { - uBalSat.Sub(&uBalSat, tx.getAddrVinValue(addrDesc, mapAssetMempool)) - } - if page == 0 { - if option == AccountDetailsTxidHistory { - txids = append(txids, tx.Txid) - } else if option >= AccountDetailsTxHistoryLight { - txs = append(txs, tx) + if option == AccountDetailsBasic { + // Basic detail: skip per-tx loading. The count is the raw mempool + // index length; it may include entries that have just been confirmed + // but not yet evicted from the mempool. unconfirmedBalance/sending/ + // receiving are not computed and will be omitted from the response. + unconfirmedTxs = len(txm) + } else { + for _, txid := range txm { + tx, err := w.getTransaction(txid, false, true, addresses) + // mempool transaction may fail + if err != nil || tx == nil { + glog.Warning("GetTransaction in mempool: ", err) + } else { + // skip already confirmed txs, mempool may be out of sync + if tx.Confirmations == 0 { + unconfirmedTxs++ + uBalReceiving.Add(&uBalReceiving, tx.getAddrVoutValue(addrDesc)) + tx.addAddrVoutAssetMempool(addrDesc, assetMempool) // SYSCOIN + // ethereum has a different logic - value not in input and add maximum possible fees + if w.chainType == bchain.ChainEthereumType { + uBalSending.Add(&uBalSending, tx.getAddrEthereumTypeMempoolInputValue(addrDesc)) + } else { + uBalSending.Add(&uBalSending, tx.getAddrVinValue(addrDesc)) + tx.addAddrVinAssetMempool(addrDesc, assetMempool) // SYSCOIN + } + if page == 0 { + if option == AccountDetailsTxidHistory { + txids = append(txids, tx.Txid) + } else if option >= AccountDetailsTxHistoryLight { + setIsOwnAddress(tx, address) + txs = append(txs, tx) + } } } } @@ -1248,7 +2284,7 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco if option == AccountDetailsTxidHistory { txids = append(txids, txid) } else { - tx, err := w.txFromTxid(txid, bestheight, option, nil) + tx, err := w.txFromTxid(txid, bestheight, option, nil, addresses) if err != nil { return nil, err } @@ -1257,309 +2293,310 @@ 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 } - if option > AccountDetailsBasic { - if ba.AssetBalances != nil { - tokens = make(bchain.Tokens, 0, len(ba.AssetBalances)) - for k, v := range ba.AssetBalances { - assetGuid := strconv.FormatUint(k, 10) - dbAsset, errAsset := w.db.GetAsset(k, nil) - if errAsset != nil { - dbAsset = &bchain.Asset{Transactions: 0, AssetObj: wire.AssetType{Symbol: []byte(assetGuid), Precision: 8}} - } - totalAssetReceived := bchain.ReceivedSatFromBalances(v.BalanceSat, v.SentSat) - var unconfirmedBalanceSat *big.Int - unconfirmedTransfers := 0 - mempoolAsset, ok := mapAssetMempool[assetGuid] - if ok { - unconfirmedBalanceSat = mempoolAsset.ValueSat - unconfirmedTransfers = mempoolAsset.UnconfirmedTxs - // set address to used to ensure uniqueness - mempoolAsset.Used = true - mapAssetMempool[assetGuid] = mempoolAsset - } - tokens = append(tokens, &bchain.Token{ - Type: bchain.SPTTokenType, - Name: address, - Decimals: int(dbAsset.AssetObj.Precision), - Symbol: string(dbAsset.AssetObj.Symbol), - BalanceSat: (*bchain.Amount)(v.BalanceSat), - UnconfirmedBalanceSat: (*bchain.Amount)(unconfirmedBalanceSat), - TotalReceivedSat: (*bchain.Amount)(totalAssetReceived), - TotalSentSat: (*bchain.Amount)(v.SentSat), - AssetGuid: assetGuid, - Transfers: v.Transfers, - UnconfirmedTransfers: unconfirmedTransfers, - }) - } - } - - for k, v := range mapAssetMempool { - if v.Used == true { - continue + var secondaryRate, totalSecondaryValue, totalBaseValue, secondaryValue float64 + if secondaryCoin != "" { + 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] + if found { + secondaryRate = float64(r) } - assetGuid, err := strconv.ParseUint(k, 10, 64) - if err != nil { - return nil, err - } - dbAsset, errAsset := w.db.GetAsset(assetGuid, nil) - if errAsset != nil { - dbAsset = &bchain.Asset{Transactions: 0, AssetObj: wire.AssetType{Symbol: []byte(k), Precision: 8}} - } - tokens = append(tokens, &bchain.Token{ - Type: bchain.SPTTokenType, - Name: address, - Decimals: int(dbAsset.AssetObj.Precision), - Symbol: string(dbAsset.AssetObj.Symbol), - BalanceSat: &bchain.Amount{}, - UnconfirmedBalanceSat: (*bchain.Amount)(v.ValueSat), - TotalReceivedSat: &bchain.Amount{}, - TotalSentSat: &bchain.Amount{}, - AssetGuid: k, - Transfers: 0, - UnconfirmedTransfers: v.UnconfirmedTxs, - }) } - - sort.Sort(tokens) + secondaryValue = secondaryRate * balance + if w.chainType == bchain.ChainEthereumType { + totalBaseValue += balance + ed.tokensBaseValue + totalSecondaryValue = secondaryRate * totalBaseValue + } + } + var unconfirmedBalanceSat *Amount + if option > AccountDetailsBasic { + uBalSat.Sub(&uBalReceiving, &uBalSending) + unconfirmedBalanceSat = (*Amount)(&uBalSat) + } + var tokensAsset Tokens // SYSCOIN + usedAssetTokens := 0 // SYSCOIN + if w.chainType == bchain.ChainBitcoinType && option > AccountDetailsBasic { + tokensAsset, usedAssetTokens, err = w.getSyscoinAddressAssetTokens(address, ba.AssetBalances, assetMempool) + if err != nil { + return nil, err + } + } + 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, - BalanceSat: (*bchain.Amount)(&ba.BalanceSat), - TotalReceivedSat: (*bchain.Amount)(totalReceived), - TotalSentSat: (*bchain.Amount)(totalSent), + BalanceSat: (*Amount)(&ba.BalanceSat), + TotalReceivedSat: (*Amount)(totalReceived), + TotalSentSat: (*Amount)(totalSent), Txs: int(ba.Txs), - NonTokenTxs: nonTokenTxs, - UnconfirmedBalanceSat: (*bchain.Amount)(&uBalSat), + NonTokenTxs: ed.nonContractTxs, + InternalTxs: ed.internalTxs, + UnconfirmedBalanceSat: unconfirmedBalanceSat, UnconfirmedTxs: unconfirmedTxs, + UnconfirmedSending: amountOrNil(&uBalSending), + UnconfirmedReceiving: amountOrNil(&uBalReceiving), Transactions: txs, Txids: txids, - Tokens: tokens, - Erc20Contract: erc20c, - Nonce: nonce, - } - glog.Info("GetAddress ", address, ", ", time.Since(start)) + UsedAssetTokens: usedAssetTokens, // SYSCOIN + Tokens: ed.tokens, + TokensAsset: tokensAsset, // SYSCOIN + SecondaryValue: secondaryValue, + TokensBaseValue: ed.tokensBaseValue, + TokensSecondaryValue: ed.tokensSecondaryValue, + TotalBaseValue: totalBaseValue, + TotalSecondaryValue: totalSecondaryValue, + ContractInfo: contractInfoResultFromBchain(ed.contractInfo, contractInfoBestHeight), + Nonce: ed.nonce, + ConfirmedNonce: ed.confirmedNonce, + 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.Standard == bchain.ERC20TokenStandard { + r.Erc20Contract = r.ContractInfo + } + glog.V(1).Info("GetAddress-", option, " ", address, ", ", time.Since(start)) return r, nil } -// find assets from cache that contain filter -func (w *Worker) FindAssetsFromFilter(filter string) []*AssetsSpecific { - start := time.Now() - if w.db.GetSetupAssetCacheFirstTime() == true { - if err := w.db.SetupAssetCache(); err != nil { - glog.Error("FindAssetsFromFilter SetupAssetCache ", err) - return nil - } - w.db.SetSetupAssetCacheFirstTime(false) +// 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 } - assetDetails := make([]*AssetsSpecific, 0) - filterLower := strings.ToLower(filter) - filterLower = strings.Replace(filterLower, "0x", "", -1) - for guid, assetCached := range *w.db.GetAssetCache() { - foundAsset := false - symbol := string(assetCached.AssetObj.Symbol) - symbolLower := strings.ToLower(symbol) - if strings.Contains(symbolLower, filterLower) { - foundAsset = true - } else if len(assetCached.AssetObj.Contract) > 0 && len(filterLower) > 5 { - contractStr := ethcommon.BytesToAddress(assetCached.AssetObj.Contract).Hex() - contractLower := strings.ToLower(contractStr) - if strings.Contains(contractLower, filterLower) { - foundAsset = true - } - } - if foundAsset == true { - assetSpecific := AssetsSpecific{ - AssetGuid: strconv.FormatUint(guid, 10), - Symbol: string(assetCached.AssetObj.Symbol), - Contract: ethcommon.BytesToAddress(assetCached.AssetObj.Contract).Hex(), - TotalSupply: (*bchain.Amount)(big.NewInt(assetCached.AssetObj.TotalSupply)), - Decimals: int(assetCached.AssetObj.Precision), - Txs: int(assetCached.Transactions), - MetaData: string(assetCached.MetaData), - } - assetDetails = append(assetDetails, &assetSpecific) - } + return (*Amount)(num) +} + +func (w *Worker) balanceHistoryHeightsFromTo(fromTimestamp, toTimestamp int64) (uint32, uint32, uint32, uint32) { + fromUnix := uint32(0) + toUnix := maxUint32 + fromHeight := uint32(0) + toHeight := maxUint32 + if fromTimestamp != 0 { + fromUnix = uint32(fromTimestamp) + fromHeight = w.is.GetBlockHeightOfTime(fromUnix) } - glog.Info("FindAssetsFromFilter, ", time.Since(start)) - return assetDetails + if toTimestamp != 0 { + toUnix = uint32(toTimestamp) + toHeight = w.is.GetBlockHeightOfTime(toUnix) + } + return fromUnix, fromHeight, toUnix, toHeight } -func (w *Worker) FindAssets(filter string, page int, txsOnPage int) *Assets { - page-- - if page < 0 { - page = 0 +func (w *Worker) processInternalTransactionsForBalanceHistory(addrDesc bchain.AddressDescriptor, txid string, bh *BalanceHistory) error { + if !bchain.ProcessInternalTransactions { + return nil } - start := time.Now() - assetsFiltered := w.FindAssetsFromFilter(filter) - var from, to int - var pg Paging - pg, from, to, page = computePaging(len(assetsFiltered), page, txsOnPage) - assetDetails := make([]*AssetsSpecific, to-from) - for i := from; i < to; i++ { - assetDetails[i-from] = assetsFiltered[i] + internalData, err := w.db.GetEthereumInternalData(txid) + if err != nil { + return err } - r := &Assets{ - AssetDetails: assetDetails, - Paging: pg, - NumAssets: len(assetsFiltered), - Filter: filter, + if internalData == nil { + return nil } - glog.Info("FindAssets filter: ", filter, ", ", time.Since(start)) - return r -} -func (w *Worker) GetChainTips() (string, error) { - result, err := w.chain.GetChainTips() - if err != nil { - return "", err + + 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 result, nil + + return nil } -func (w *Worker) GetSPVProof(hash string) (string, error) { - result, err := w.chain.GetSPVProof(hash) - if err != nil { - return "", err +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) } - return result, nil + (*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &feesSat) } -// GetAsset gets transactions for given asset -func (w *Worker) GetAsset(asset string, page int, txsOnPage int, option AccountDetails, filter *AddressFilter) (*Asset, error) { - start := time.Now() - page-- - if page < 0 { - page = 0 +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 } - var ( - txm []string - txs []*Tx - txids []string - pg Paging - unconfirmedTxs int - totalResults int - uBalSat big.Int - ) - var err error - assetGuid, err := strconv.ParseUint(asset, 10, 64) + 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 nil, err + return false, err } - dbAsset, errAsset := w.db.GetAsset(assetGuid, nil) - if errAsset != nil { - return nil, NewAPIError("Asset not found", true) + if bytes.Equal(addrDesc, txAddrDesc) { + (*big.Int)(bh.ReceivedSat).Add((*big.Int)(bh.ReceivedSat), &bchainVout.ValueSat) } - // totalResults is known only if there is no filter - if filter.FromHeight == 0 && filter.ToHeight == 0 { - totalResults = int(dbAsset.Transactions) - } else { - totalResults = -1 + if _, found := selfAddrDesc[string(txAddrDesc)]; found { + countSentToSelf = true } - // process mempool, only if toHeight is not specified - if filter.ToHeight == 0 && !filter.OnlyConfirmed { - txm, err = w.getAssetTxids(assetGuid, true, filter, maxInt) + 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 nil, errors.Annotatef(err, "getAssetTxids %v true", asset) + return err } - for _, txid := range txm { - tx, err := w.GetTransaction(txid, false, false) - // mempool transaction may fail - if err != nil || tx == nil { - glog.Warning("GetTransaction in mempool: ", err) - } else { - // skip already confirmed txs, mempool may be out of sync - if tx.Confirmations == 0 { - uBalSat.Add(&uBalSat, tx.getAssetOutValue(asset)) - unconfirmedTxs++ - if page == 0 { - if option == AccountDetailsTxidHistory { - txids = append(txids, tx.Txid) - } else if option >= AccountDetailsTxHistoryLight { - txs = append(txs, tx) - } - } - } - } + + // Internal transfers are shared accounting for Ethereum-like families. + if err := w.processInternalTransactionsForBalanceHistory(addrDesc, txid, bh); err != nil { + return err } } - // get tx history if requested by option or check mempool if there are some transactions for a new address - if option >= AccountDetailsTxidHistory { - txc, err := w.getAssetTxids(assetGuid, false, filter, (page+1)*txsOnPage) - if err != nil { - return nil, errors.Annotatef(err, "getAssetTxids %v false", asset) + + for i := range bchainTx.Vin { + bchainVin := &bchainTx.Vin[i] + if len(bchainVin.Addresses) == 0 { + continue } - bestheight, _, err := w.db.GetBestBlock() + + txAddrDesc, err := w.chainParser.GetAddrDescFromAddress(bchainVin.Addresses[0]) if err != nil { - return nil, errors.Annotatef(err, "GetBestBlock") + return err } - var from, to int - pg, from, to, page = computePaging(len(txc), page, txsOnPage) - if len(txc) >= txsOnPage { - if totalResults < 0 { - pg.TotalPages = -1 - } else { - pg, _, _, _ = computePaging(totalResults, page, txsOnPage) - } + if !bytes.Equal(addrDesc, txAddrDesc) { + continue } - for i := from; i < to; i++ { - txid := txc[i] - if option == AccountDetailsTxidHistory { - txids = append(txids, txid) - } else { - tx, err := w.txFromTxid(txid, bestheight, option, nil) - if err != nil { - return nil, err + + 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) } - txs = append(txs, tx) } } + // Fees always reduce spendable balance for sender-side matches. + addEthereumFeesToBalanceHistory(ethTxData, bh) } - r := &Asset{ - AssetDetails: &AssetSpecific{ - AssetGuid: asset, - Symbol: string(dbAsset.AssetObj.Symbol), - Contract: ethcommon.BytesToAddress(dbAsset.AssetObj.Contract).Hex(), - TotalSupply: (*bchain.Amount)(big.NewInt(dbAsset.AssetObj.TotalSupply)), - MaxSupply: (*bchain.Amount)(big.NewInt(dbAsset.AssetObj.MaxSupply)), - Decimals: int(dbAsset.AssetObj.Precision), - MetaData: string(dbAsset.MetaData), - }, - Paging: pg, - UnconfirmedBalanceSat: (*bchain.Amount)(&uBalSat), - UnconfirmedTxs: unconfirmedTxs, - Transactions: txs, - Txs: int(dbAsset.Transactions), - Txids: txids, + + 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) } - glog.Info("GetAsset ", asset, ", ", time.Since(start)) - return r, nil } -func (w *Worker) balanceHistoryHeightsFromTo(fromTimestamp, toTimestamp int64) (uint32, uint32, uint32, uint32) { - fromUnix := uint32(0) - toUnix := maxUint32 - fromHeight := uint32(0) - toHeight := maxUint32 - if fromTimestamp != 0 { - fromUnix = uint32(fromTimestamp) - fromHeight = w.is.GetBlockHeightOfTime(fromUnix) +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 + } + } } - if toTimestamp != 0 { - toUnix = uint32(toTimestamp) - toHeight = w.is.GetBlockHeightOfTime(toUnix) + 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) + } + } } - return fromUnix, fromHeight, toUnix, toHeight } func (w *Worker) balanceHistoryForTxid(addrDesc bchain.AddressDescriptor, txid string, fromUnix, toUnix uint32, selfAddrDesc map[string]struct{}) (*BalanceHistory, error) { var time uint32 var err error - var ta *bchain.TxAddresses + var ta *db.TxAddresses var bchainTx *bchain.Tx var height uint32 if w.chainType == bchain.ChainBitcoinType { @@ -1591,148 +2628,45 @@ func (w *Worker) balanceHistoryForTxid(addrDesc bchain.AddressDescriptor, txid s bh := BalanceHistory{ Time: time, Txs: 1, - SentSat: &bchain.Amount{}, - ReceivedSat: &bchain.Amount{}, - SentToSelfSat: &bchain.Amount{}, + ReceivedSat: &Amount{}, + SentSat: &Amount{}, + 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 - } - if tai.AssetInfo != nil { - if bh.Tokens == nil { - bh.Tokens = map[string]*TokenBalanceHistory{} - } - assetGuid := strconv.FormatUint(tai.AssetInfo.AssetGuid, 10) - bhaToken, ok := bh.Tokens[assetGuid] - if !ok { - bhaToken = &TokenBalanceHistory{SentSat: &bchain.Amount{}, ReceivedSat: &bchain.Amount{}} - bh.Tokens[assetGuid] = bhaToken - } - (*big.Int)(bhaToken.SentSat).Add((*big.Int)(bhaToken.SentSat), (*big.Int)(tai.AssetInfo.ValueSat)) - } - } - } - 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 tao.AssetInfo != nil { - if bh.Tokens == nil { - bh.Tokens = map[string]*TokenBalanceHistory{} - } - assetGuid := strconv.FormatUint(tao.AssetInfo.AssetGuid, 10) - bhaToken, ok := bh.Tokens[assetGuid] - if !ok { - bhaToken = &TokenBalanceHistory{SentSat: &bchain.Amount{}, ReceivedSat: &bchain.Amount{}} - bh.Tokens[assetGuid] = bhaToken - } - (*big.Int)(bhaToken.ReceivedSat).Add((*big.Int)(bhaToken.ReceivedSat), (*big.Int)(tao.AssetInfo.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 - } - } - } - } - 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]float64) - 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 -} +// DefaultBalanceHistoryMaxTxsREST / DefaultBalanceHistoryMaxTxsWS cap how many +// transactions a single balance-history request may aggregate, split by transport. +// The cap bounds per-request DB work (one read per aggregated transaction). REST is +// tighter (open, unauthenticated surface); WS is generous because Trezor Suite +// requests full account history over WS for its balance graph. Override or disable +// (0) via _{WS,REST}_BALANCE_HISTORY_MAX_TXS (_BALANCE_HISTORY_MAX_TXS +// sets both). +const ( + DefaultBalanceHistoryMaxTxsREST = 250000 + DefaultBalanceHistoryMaxTxsWS = 1000000 +) -// GetBalanceHistory returns history of balance for given address -func (w *Worker) GetBalanceHistory(address string, fromTimestamp, toTimestamp int64, currencies []string, groupBy uint32) (BalanceHistories, error) { +// Transport labels for the balance-history metrics, identifying which surface +// served a request. Passed by the caller (the worker is transport-agnostic). +const ( + BalanceHistoryTransportWS = "ws" + BalanceHistoryTransportREST = "rest" +) + +// GetBalanceHistory returns history of balance for given address. maxTxs bounds +// how many transactions in the requested range may be aggregated (0 = unlimited); +// the caller supplies the transport-specific cap (WS vs REST). transport labels +// the emitted metrics with the serving surface. +func (w *Worker) GetBalanceHistory(address string, fromTimestamp, toTimestamp int64, currencies []string, groupBy uint32, maxTxs int, transport string) (BalanceHistories, error) { currencies = removeEmpty(currencies) bhs := make(BalanceHistories, 0) start := time.Now() @@ -1740,14 +2674,42 @@ 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 } - txs, err := w.getAddressTxids(addrDesc, false, &AddressFilter{AssetsMask: bchain.AllMask, Vout: AddressFilterVoutOff, FromHeight: fromHeight, ToHeight: toHeight}, maxInt) + // Bound the work: each transaction in the range costs a DB read below, so an + // unbounded scan over a heavy address is a cheap-to-send DoS. Fetch at most + // one more than the cap so the overflow is detectable, then reject rather + // than silently truncate (which would return a wrong balance history). + maxResults := maxInt + if maxTxs > 0 { + maxResults = maxTxs + 1 + } + txs, err := w.getAddressTxids(addrDesc, false, &AddressFilter{Vout: AddressFilterVoutOff, FromHeight: fromHeight, ToHeight: toHeight}, maxResults) if err != nil { return nil, err } + if w.metrics != nil { + w.metrics.BalanceHistoryTxs.With(common.Labels{"transport": transport, "path": "address"}).Observe(float64(len(txs))) + } + if maxTxs > 0 && len(txs) > maxTxs { + if w.metrics != nil { + w.metrics.BalanceHistoryCapExceeded.With(common.Labels{"transport": transport, "path": "address"}).Inc() + } + return nil, NewAPIError(fmt.Sprintf("balance history spans more than %d transactions in the requested range; narrow the from/to range", maxTxs), true) + } selfAddrDesc := map[string]struct{}{string(addrDesc): {}} for txi := len(txs) - 1; txi >= 0; txi-- { bh, err := w.balanceHistoryForTxid(addrDesc, txs[txi], fromUnix, toUnix, selfAddrDesc) @@ -1759,36 +2721,40 @@ 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 } - glog.Info("GetBalanceHistory ", address, ", blocks ", fromHeight, "-", toHeight, ", count ", len(bha), ", ", time.Since(start)) + glog.V(1).Info("GetBalanceHistory ", address, ", blocks ", fromHeight, "-", toHeight, ", count ", len(bha), ", ", time.Since(start)) return bha, nil } 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() } } -func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *bchain.AddrBalance, onlyConfirmed bool, onlyMempool bool) ([]Utxo, error) { +func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *db.AddrBalance, onlyConfirmed bool, onlyMempool bool, knownBestHeight *uint32) (Utxos, error) { w.waitForBackendSync() var err error - utxos := make([]Utxo, 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 + utxos := make(Utxos, 0, 8) + // 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{}) if !onlyConfirmed { // get utxo from mempool - txm, err := w.getAddressTxids(addrDesc, true, &AddressFilter{AssetsMask: bchain.AllMask, Vout: AddressFilterVoutOff}, maxInt) + txm, err := w.getAddressTxids(addrDesc, true, &AddressFilter{Vout: AddressFilterVoutOff}, maxInt) if err != nil { return nil, err } @@ -1805,7 +2771,7 @@ func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *bchain.A // 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{}{} } } } @@ -1816,24 +2782,22 @@ func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *bchain.A 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 { coinbase = true } - utxoTmp := Utxo{ + utxos = append(utxos, Utxo{ Txid: bchainTx.Txid, Vout: int32(i), - AmountSat: (*bchain.Amount)(&vout.ValueSat), + AmountSat: (*Amount)(&vout.ValueSat), Locktime: bchainTx.LockTime, Coinbase: coinbase, - } - if vout.AssetInfo != nil { - utxoTmp.AssetInfo = &AssetInfo{AssetGuid: strconv.FormatUint(vout.AssetInfo.AssetGuid, 10), ValueSat: (*bchain.Amount)(vout.AssetInfo.ValueSat)} - } - utxos = append(utxos, utxoTmp) - inMempool[bchainTx.Txid] = struct{}{} + // SYSCOIN + AssetInfo: w.assetInfoToAPI(vout.AssetInfo), + }) + inMempool[bchainTx.Txid+":"+strconv.Itoa(i)] = struct{}{} } } } @@ -1844,18 +2808,23 @@ func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *bchain.A if !onlyMempool { // get utxo from index if ba == nil { - ba, err = w.db.GetAddrDescBalance(addrDesc, bchain.AddressBalanceDetailUTXO) + ba, err = w.db.GetAddrDescBalance(addrDesc, db.AddressBalanceDetailUTXO) if err != nil { return nil, NewAPIError(fmt.Sprintf("Address not found, %v", err), true) } } // ba can be nil if the address is only in mempool! if ba != nil && len(ba.Utxos) > 0 { - b, _, err := w.db.GetBestBlock() - if err != nil { - return nil, err + var bestheight int + if knownBestHeight != nil { + bestheight = int(*knownBestHeight) + } else { + b, _, err := w.db.GetBestBlock() + if err != nil { + return nil, err + } + bestheight = int(b) } - bestheight := int(b) var checksum big.Int checksum.Set(&ba.BalanceSat) // go backwards to get the newest first @@ -1865,7 +2834,7 @@ func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *bchain.A 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 @@ -1879,20 +2848,18 @@ func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *bchain.A coinbase = true } } - _, e = inMempool[txid] + _, e = inMempool[txid+":"+strconv.Itoa(int(utxo.Vout))] if !e { - utxoTmp := Utxo{ + utxos = append(utxos, Utxo{ Txid: txid, Vout: utxo.Vout, - AmountSat: (*bchain.Amount)(&utxo.ValueSat), + AmountSat: (*Amount)(&utxo.ValueSat), Height: int(utxo.Height), Confirmations: confirmations, Coinbase: coinbase, - } - if utxo.AssetInfo != nil { - utxoTmp.AssetInfo = &AssetInfo{AssetGuid: strconv.FormatUint(utxo.AssetInfo.AssetGuid, 10), ValueSat: (*bchain.Amount)(utxo.AssetInfo.ValueSat)} - } - utxos = append(utxos, utxoTmp) + // SYSCOIN + AssetInfo: w.assetInfoToAPI(utxo.AssetInfo), + }) } } checksum.Sub(&checksum, &utxo.ValueSat) @@ -1907,55 +2874,20 @@ func (w *Worker) getAddrDescUtxo(addrDesc bchain.AddressDescriptor, ba *bchain.A // GetAddressUtxo returns unspent outputs for given address func (w *Worker) GetAddressUtxo(address string, onlyConfirmed bool) (Utxos, error) { - var utxoRes Utxos if w.chainType != bchain.ChainBitcoinType { - return utxoRes, NewAPIError("Not supported", true) + return nil, NewAPIError("Not supported", true) } - assets := make([]*AssetSpecific, 0, 0) - assetsMap := make(map[uint64]bool, 0) start := time.Now() addrDesc, err := w.chainParser.GetAddrDescFromAddress(address) if err != nil { - return utxoRes, NewAPIError(fmt.Sprintf("Invalid address '%v', %v", address, err), true) + return nil, NewAPIError(fmt.Sprintf("Invalid address '%v', %v", address, err), true) } - utxoRes.Utxos, err = w.getAddrDescUtxo(addrDesc, nil, onlyConfirmed, false) + r, err := w.getAddrDescUtxo(addrDesc, nil, onlyConfirmed, false, nil) if err != nil { - return utxoRes, err - } - // add applicable assets to UTXO - for j := range utxoRes.Utxos { - a := &utxoRes.Utxos[j] - if a.AssetInfo != nil { - assetGuid, err := strconv.ParseUint(a.AssetInfo.AssetGuid, 10, 64) - if err != nil { - return utxoRes, err - } - dbAsset, errAsset := w.db.GetAsset(assetGuid, nil) - if errAsset != nil { - dbAsset = &bchain.Asset{Transactions: 0, AssetObj: wire.AssetType{Symbol: []byte(a.AssetInfo.AssetGuid), Precision: 8}} - } - // add unique base assets - var _, ok = assetsMap[assetGuid] - if ok { - continue - } - assetsMap[assetGuid] = true - assetDetails := &AssetSpecific{ - AssetGuid: strconv.FormatUint(assetGuid, 10), - Symbol: string(dbAsset.AssetObj.Symbol), - Contract: ethcommon.BytesToAddress(dbAsset.AssetObj.Contract).Hex(), - TotalSupply: (*bchain.Amount)(big.NewInt(dbAsset.AssetObj.TotalSupply)), - MaxSupply: (*bchain.Amount)(big.NewInt(dbAsset.AssetObj.MaxSupply)), - Decimals: int(dbAsset.AssetObj.Precision), - } - assets = append(assets, assetDetails) - } - } - glog.Info("GetAddressUtxo ", address, ", ", len(utxoRes.Utxos), " utxos, ", len(assets), " assets, ", time.Since(start)) - if len(assets) > 0 { - utxoRes.Assets = assets + return nil, err } - return utxoRes, nil + glog.V(1).Info("GetAddressUtxo ", address, ", ", len(r), " utxos, ", time.Since(start)) + return r, nil } // GetBlocks returns BlockInfo for blocks on given page @@ -1972,7 +2904,7 @@ func (w *Worker) GetBlocks(page int, blocksOnPage int) (*Blocks, error) { } pg, from, to, page := computePaging(bestheight+1, page, blocksOnPage) r := &Blocks{Paging: pg} - r.Blocks = make([]bchain.DbBlockInfo, to-from) + r.Blocks = make([]db.BlockInfo, to-from) for i := from; i < to; i++ { bi, err := w.db.GetBlockInfo(uint32(bestheight - i)) if err != nil { @@ -1984,151 +2916,10 @@ func (w *Worker) GetBlocks(page int, blocksOnPage int) (*Blocks, error) { } r.Blocks[i-from] = *bi } - glog.Info("GetBlocks page ", page, ", ", time.Since(start)) + glog.V(1).Info("GetBlocks page ", page, ", ", time.Since(start)) 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 *db.CurrencyRatesTicker) (*db.ResultTickerAsString, error) { - currencies = removeEmpty(currencies) - if len(currencies) == 0 { - // Return all available ticker rates - return &db.ResultTickerAsString{ - 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]float64) - for _, currency := range currencies { - currency = strings.ToLower(currency) - if rate, found := ticker.Rates[currency]; found { - rates[currency] = rate - } else { - rates[currency] = -1 - } - } - return &db.ResultTickerAsString{ - Timestamp: ticker.Timestamp.UTC().Unix(), - Rates: rates, - }, nil -} - -// GetFiatRatesForBlockID returns fiat rates for block height or block hash -func (w *Worker) GetFiatRatesForBlockID(bid string, currencies []string) (*db.ResultTickerAsString, error) { - var ticker *db.CurrencyRatesTicker - bi, err := w.getBlockInfoFromBlockID(bid) - if err != nil { - if err == bchain.ErrBlockNotFound { - return nil, NewAPIError(fmt.Sprintf("Block %v not found", bid), true) - } - return nil, NewAPIError(fmt.Sprintf("Block %v not found, error: %v", bid, err), false) - } - dbi := &bchain.DbBlockInfo{Time: bi.Time} // get Unix timestamp from block - tm := time.Unix(dbi.Time, 0) // convert it to Time object - ticker, err = w.db.FiatRatesFindTicker(&tm) - 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) - if err != nil { - return nil, err - } - return result, nil -} - -// GetCurrentFiatRates returns last available fiat rates -func (w *Worker) GetCurrentFiatRates(currencies []string) (*db.ResultTickerAsString, error) { - ticker, err := w.db.FiatRatesFindLastTicker() - 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!"), true) - } - result, err := w.getFiatRatesResult(currencies, ticker) - if err != nil { - return nil, err - } - return result, nil -} - -// makeErrorRates returns a map of currrencies, with each value equal to -1 -// used when there was an error finding ticker -func makeErrorRates(currencies []string) map[string]float64 { - rates := make(map[string]float64) - 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) (*db.ResultTickersAsString, error) { - if len(timestamps) == 0 { - return nil, NewAPIError("No timestamps provided", true) - } - currencies = removeEmpty(currencies) - - ret := &db.ResultTickersAsString{} - for _, timestamp := range timestamps { - date := time.Unix(timestamp, 0) - date = date.UTC() - ticker, err := w.db.FiatRatesFindTicker(&date) - if err != nil { - glog.Errorf("Error finding ticker for date %v. Error: %v", date, err) - ret.Tickers = append(ret.Tickers, db.ResultTickerAsString{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)}) - continue - } else if ticker == nil { - ret.Tickers = append(ret.Tickers, db.ResultTickerAsString{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)}) - continue - } - result, err := w.getFiatRatesResult(currencies, ticker) - if err != nil { - ret.Tickers = append(ret.Tickers, db.ResultTickerAsString{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)}) - continue - } - ret.Tickers = append(ret.Tickers, *result) - } - return ret, nil -} - -// GetFiatRatesTickersList returns the list of available fiatRates tickers -func (w *Worker) GetFiatRatesTickersList(timestamp int64) (*db.ResultTickerListAsString, error) { - date := time.Unix(timestamp, 0) - date = date.UTC() - - ticker, err := w.db.FiatRatesFindTicker(&date) - 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 &db.ResultTickerListAsString{ - 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 @@ -2252,12 +3043,12 @@ func (w *Worker) GetFeeStats(bid string) (*FeeStats, error) { } } - glog.Info("GetFeeStats ", bid, " (", len(feesPerKb), " txs), ", time.Since(start)) + glog.V(1).Info("GetFeeStats ", bid, " (", len(feesPerKb), " txs), ", time.Since(start)) return &FeeStats{ TxCount: len(feesPerKb), AverageFeePerKb: averageFeePerKb, - TotalFeesSat: (*bchain.Amount)(totalFeesSat), + TotalFeesSat: (*Amount)(totalFeesSat), DecilesFeePerKb: deciles, }, nil } @@ -2276,7 +3067,7 @@ func (w *Worker) GetBlock(bid string, page int, txsOnPage int) (*Block, error) { } return nil, NewAPIError(fmt.Sprintf("Block not found, %v", err), true) } - dbi := &bchain.DbBlockInfo{ + dbi := &db.BlockInfo{ Hash: bi.Hash, Height: bi.Height, Time: bi.Time, @@ -2289,8 +3080,9 @@ func (w *Worker) GetBlock(bid string, page int, txsOnPage int) (*Block, error) { pg, from, to, page := computePaging(txCount, page, txsOnPage) txs := make([]*Tx, to-from) txi := 0 + addresses := w.newAddressesMapForAliases() for i := from; i < to; i++ { - txs[txi], err = w.txFromTxid(bi.Txids[i], bestheight, AccountDetailsTxHistoryLight, dbi) + txs[txi], err = w.txFromTxid(bi.Txids[i], bestheight, AccountDetailsTxHistoryLight, dbi, addresses) if err != nil { return nil, err } @@ -2304,7 +3096,7 @@ func (w *Worker) GetBlock(bid string, page int, txsOnPage int) (*Block, error) { } txs = txs[:txi] bi.Txids = nil - glog.Info("GetBlock ", bid, ", page ", page, ", ", time.Since(start)) + glog.V(1).Info("GetBlock ", bid, ", page ", page, ", ", time.Since(start)) return &Block{ Paging: pg, BlockInfo: BlockInfo{ @@ -2322,12 +3114,13 @@ func (w *Worker) GetBlock(bid string, page int, txsOnPage int) (*Block, error) { Txids: bi.Txids, Version: bi.Version, }, - TxCount: txCount, - Transactions: txs, + TxCount: txCount, + Transactions: txs, + AddressAliases: w.getAddressAliases(addresses), }, 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 == "" { @@ -2343,6 +3136,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() @@ -2360,7 +3195,7 @@ func (w *Worker) ComputeFeeStats(blockFrom, blockTo int, stopCompute chan os.Sig } // process only blocks with enough transactions if len(bi.Txids) > 20 { - dbi := &bchain.DbBlockInfo{ + dbi := &db.BlockInfo{ Hash: bi.Hash, Height: bi.Height, Time: bi.Time, @@ -2378,7 +3213,7 @@ func (w *Worker) ComputeFeeStats(blockFrom, blockTo int, stopCompute chan os.Sig glog.Info("ComputeFeeStats interrupted at height ", block) return db.ErrOperationInterrupted default: - tx, err := w.txFromTxid(txid, bestheight, AccountDetailsTxHistoryLight, dbi) + tx, err := w.txFromTxid(txid, bestheight, AccountDetailsTxHistoryLight, dbi, nil) if err != nil { return err } @@ -2403,11 +3238,75 @@ func (w *Worker) ComputeFeeStats(blockFrom, blockTo int, stopCompute chan os.Sig return nil } +func nonZeroTime(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t +} + +const ( + systemInfoSyncStartGrace = 5 * time.Second + systemInfoEthereumStaleBlocks = 12 + // systemInfoEthereumSyncedGap is how far the indexed height may trail the + // backend tip and still count as synchronized. It covers the one-block window + // between the feed tip advancing and that block being connected, which would + // otherwise flap the status on a fast/archive EVM chain. + systemInfoEthereumSyncedGap = 1 +) + +func systemInfoInSync(inSync bool, initialSync bool, chainType bchain.ChainType, bestHeight uint32, backendBlocks int, lastBlockTime, startSync, now time.Time, blockPeriod time.Duration) bool { + if !inSync && !initialSync { + // If less than 5 seconds into syncing, return inSync=true to avoid short + // out-of-sync reports that confuse monitoring. + if startSync.Add(systemInfoSyncStartGrace).After(now) { + inSync = true + } + } + + if chainType != bchain.ChainEthereumType || blockPeriod <= 0 { + return inSync + } + + threshold := systemInfoEthereumStaleBlocks * blockPeriod + isFresh := !lastBlockTime.Add(threshold).Before(now) + + // Long EVM archive syncs can stay inside ResyncIndex while new blocks keep + // arriving. If the indexed height is at (or within one block of) the backend + // tip and the index was updated recently, report the externally observable + // state as synchronized. int64 avoids underflow if the backend momentarily + // reports a lower tip; gap >= 0 keeps an "ahead of tip" read from qualifying. + gap := int64(backendBlocks) - int64(bestHeight) + if !inSync && !initialSync && gap >= 0 && gap <= systemInfoEthereumSyncedGap && isFresh { + return true + } + + if inSync && !isFresh { + return false + } + + return inSync +} + // 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 := time.Duration(w.is.GetAvgBlockPeriod()) * time.Second + // Prefer the configured per-coin cadence (averageBlockTimeMs): it is stable, + // available before enough blocks are observed for GetAvgBlockPeriod to be + // computed (which otherwise returns 0 and disables the EVM sync checks below), + // and is the same value the tip watchdog uses. Using the duration directly also + // covers sub-second chains (e.g. Arbitrum at 250ms) that round to 0 seconds. + // Fall back to the runtime-observed average when the coin does not configure one. + if p, ok := w.chain.(interface { + AverageBlockTimeDuration() (time.Duration, error) + }); ok { + if d, err := p.AverageBlockTimeDuration(); err == nil && d > 0 { + blockPeriod = d + } + } inSyncMempool, lastMempoolTime, mempoolSize := w.is.GetMempoolSyncState() ci, err := w.chain.GetChainInfo() var backendError string @@ -2418,6 +3317,8 @@ func (w *Worker) GetSystemInfo(internal bool) (*SystemInfo, error) { // set not in sync in case of backend error inSync = false inSyncMempool = false + } else { + inSync = systemInfoInSync(inSync, w.is.InitialSync, w.chainType, bestHeight, ci.Blocks, lastBlockTime, startSync, time.Now().UTC(), blockPeriod) } var columnStats []common.InternalStateColumn var internalDBSize int64 @@ -2425,43 +3326,56 @@ func (w *Worker) GetSystemInfo(internal bool) (*SystemInfo, error) { columnStats = w.is.GetAllDBColumnStats() internalDBSize = w.is.DBSizeTotal() } + var currentFiatRatesTime time.Time + ct := w.fiatRates.GetCurrentTicker("", "") + if ct != nil { + currentFiatRatesTime = ct.Timestamp + } blockbookInfo := &BlockbookInfo{ - Coin: w.is.Coin, - Host: w.is.Host, - Version: vi.Version, - GitCommit: vi.GitCommit, - BuildTime: vi.BuildTime, - SyncMode: w.is.SyncMode, - InitialSync: w.is.InitialSync, - InSync: inSync, - BestHeight: bestHeight, - LastBlockTime: lastBlockTime, - InSyncMempool: inSyncMempool, - LastMempoolTime: lastMempoolTime, - MempoolSize: mempoolSize, - Decimals: w.chainParser.AmountDecimals(), - DbSize: w.db.DatabaseSizeOnDisk(), - DbSizeFromColumns: internalDBSize, - DbColumns: columnStats, - About: Text.BlockbookAbout, + Coin: w.is.Coin, + Network: w.is.GetNetwork(), + Host: w.is.Host, + Version: vi.Version, + GitCommit: vi.GitCommit, + BuildTime: vi.BuildTime, + SyncMode: w.is.SyncMode, + InitialSync: w.is.InitialSync, + InSync: inSync, + BestHeight: bestHeight, + LastBlockTime: lastBlockTime, + InSyncMempool: inSyncMempool, + LastMempoolTime: lastMempoolTime, + MempoolSize: mempoolSize, + Decimals: w.chainParser.AmountDecimals(), + HasFiatRates: w.is.HasFiatRates, + HasTokenFiatRates: w.is.HasTokenFiatRates, + 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, + About: Text.BlockbookAbout, } backendInfo := &common.BackendInfo{ - BackendError: backendError, - BestBlockHash: ci.Bestblockhash, - Blocks: ci.Blocks, - Chain: ci.Chain, - Difficulty: ci.Difficulty, - Headers: ci.Headers, - ProtocolVersion: ci.ProtocolVersion, - SizeOnDisk: ci.SizeOnDisk, - Subversion: ci.Subversion, - Timeoffset: ci.Timeoffset, - Version: ci.Version, - Warnings: ci.Warnings, - Consensus: ci.Consensus, + BackendError: backendError, + BestBlockHash: ci.Bestblockhash, + Blocks: ci.Blocks, + Chain: ci.Chain, + Difficulty: ci.Difficulty, + Headers: ci.Headers, + ProtocolVersion: ci.ProtocolVersion, + SizeOnDisk: ci.SizeOnDisk, + Subversion: ci.Subversion, + Timeoffset: ci.Timeoffset, + Version: ci.Version, + Warnings: ci.Warnings, + ConsensusVersion: ci.ConsensusVersion, + Consensus: ci.Consensus, } w.is.SetBackendInfo(backendInfo) - glog.Info("GetSystemInfo, ", time.Since(start)) + glog.V(1).Info("GetSystemInfo, ", time.Since(start)) return &SystemInfo{blockbookInfo, backendInfo}, nil } @@ -2494,12 +3408,18 @@ type bitcoinTypeEstimatedFee struct { lock sync.Mutex } -const bitcoinTypeEstimatedFeeCacheSize = 300 +const estimatedFeeCacheSize = 300 -var bitcoinTypeEstimatedFeeCache [bitcoinTypeEstimatedFeeCacheSize]bitcoinTypeEstimatedFee -var bitcoinTypeEstimatedFeeConservativeCache [bitcoinTypeEstimatedFeeCacheSize]bitcoinTypeEstimatedFee +var estimatedFeeCache [estimatedFeeCacheSize]bitcoinTypeEstimatedFee +var estimatedFeeConservativeCache [estimatedFeeCacheSize]bitcoinTypeEstimatedFee -func (w *Worker) cachedBitcoinTypeEstimateFee(blocks int, conservative bool, s *bitcoinTypeEstimatedFee) (big.Int, error) { +func (w *Worker) cachedEstimateFee(blocks int, conservative bool) (big.Int, error) { + var s *bitcoinTypeEstimatedFee + if conservative { + s = &estimatedFeeConservativeCache[blocks] + } else { + s = &estimatedFeeCache[blocks] + } s.lock.Lock() defer s.lock.Unlock() // 10 seconds cache @@ -2511,18 +3431,22 @@ func (w *Worker) cachedBitcoinTypeEstimateFee(blocks int, conservative bool, s * if err == nil { s.timestamp = time.Now().Unix() s.fee = fee + // store metrics for the first 32 block estimates + if blocks < 33 { + w.metrics.EstimatedFee.With(common.Labels{ + "blocks": strconv.Itoa(blocks), + "conservative": strconv.FormatBool(conservative), + }).Set(float64(fee.Int64())) + } } return fee, err } -// BitcoinTypeEstimateFee returns a fee estimation for given number of blocks +// EstimateFee returns a fee estimation for given number of blocks // it uses 10 second cache to reduce calls to the backend -func (w *Worker) BitcoinTypeEstimateFee(blocks int, conservative bool) (big.Int, error) { - if blocks >= bitcoinTypeEstimatedFeeCacheSize { +func (w *Worker) EstimateFee(blocks int, conservative bool) (big.Int, error) { + if blocks >= estimatedFeeCacheSize { return w.chain.EstimateSmartFee(blocks, conservative) } - if conservative { - return w.cachedBitcoinTypeEstimateFee(blocks, conservative, &bitcoinTypeEstimatedFeeConservativeCache[blocks]) - } - return w.cachedBitcoinTypeEstimateFee(blocks, conservative, &bitcoinTypeEstimatedFeeCache[blocks]) + return w.cachedEstimateFee(blocks, conservative) } 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..f4a2aa12fb --- /dev/null +++ b/api/worker_test.go @@ -0,0 +1,306 @@ +//go:build unittest + +package api + +import ( + "encoding/json" + "math/big" + "testing" + "time" + + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/fiat" +) + +func TestSystemInfoInSync(t *testing.T) { + now := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + oldStart := now.Add(-time.Minute) + + tests := []struct { + name string + inSync bool + initialSync bool + chainType bchain.ChainType + bestHeight uint32 + backendBlocks int + lastBlockTime time.Time + startSync time.Time + blockPeriod time.Duration + want bool + }{ + { + name: "reports evm synced when active sync loop is already at backend tip", + chainType: bchain.ChainEthereumType, + bestHeight: 100, + backendBlocks: 100, + lastBlockTime: now.Add(-10 * time.Second), + startSync: oldStart, + blockPeriod: 2 * time.Second, + want: true, + }, + { + name: "does not hide stale evm tip when heights match", + chainType: bchain.ChainEthereumType, + bestHeight: 100, + backendBlocks: 100, + lastBlockTime: now.Add(-25 * time.Second), + startSync: oldStart, + blockPeriod: 2 * time.Second, + }, + { + name: "does not report synced while local height is behind", + chainType: bchain.ChainEthereumType, + bestHeight: 90, + backendBlocks: 100, + lastBlockTime: now.Add(-10 * time.Second), + startSync: oldStart, + blockPeriod: 2 * time.Second, + }, + { + name: "reports evm synced within one block of a fresh tip", + chainType: bchain.ChainEthereumType, + bestHeight: 99, + backendBlocks: 100, + lastBlockTime: now.Add(-10 * time.Second), + startSync: oldStart, + blockPeriod: 2 * time.Second, + want: true, + }, + { + name: "reports evm synced on a sub-second chain at the tip", + chainType: bchain.ChainEthereumType, + bestHeight: 100, + backendBlocks: 100, + lastBlockTime: now.Add(-1 * time.Second), + startSync: oldStart, + blockPeriod: 250 * time.Millisecond, + want: true, + }, + { + name: "does not report synced more than one block behind tip", + chainType: bchain.ChainEthereumType, + bestHeight: 98, + backendBlocks: 100, + lastBlockTime: now.Add(-10 * time.Second), + startSync: oldStart, + blockPeriod: 2 * time.Second, + }, + { + name: "does not report synced during initial sync", + initialSync: true, + chainType: bchain.ChainEthereumType, + bestHeight: 100, + backendBlocks: 100, + lastBlockTime: now.Add(-10 * time.Second), + startSync: oldStart, + blockPeriod: 2 * time.Second, + }, + { + name: "keeps startup grace for fresh regular sync", + chainType: bchain.ChainBitcoinType, + backendBlocks: 100, + startSync: now.Add(-2 * time.Second), + want: true, + }, + { + name: "marks already synced evm stale", + inSync: true, + chainType: bchain.ChainEthereumType, + bestHeight: 100, + backendBlocks: 100, + lastBlockTime: now.Add(-25 * time.Second), + startSync: oldStart, + blockPeriod: 2 * time.Second, + }, + { + name: "keeps already synced evm fresh", + inSync: true, + chainType: bchain.ChainEthereumType, + bestHeight: 100, + backendBlocks: 100, + lastBlockTime: now.Add(-10 * time.Second), + startSync: oldStart, + blockPeriod: 2 * time.Second, + want: true, + }, + { + name: "does not extend tip equality rescue to bitcoin", + chainType: bchain.ChainBitcoinType, + bestHeight: 100, + backendBlocks: 100, + lastBlockTime: now.Add(-10 * time.Second), + startSync: oldStart, + blockPeriod: 2 * time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := systemInfoInSync(tt.inSync, tt.initialSync, tt.chainType, tt.bestHeight, tt.backendBlocks, tt.lastBlockTime, tt.startSync, now, tt.blockPeriod) + if got != tt.want { + t.Fatalf("systemInfoInSync() = %v, want %v", got, tt.want) + } + }) + } +} + +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 90dd55890f..9b8ad84544 100644 --- a/api/xpub.go +++ b/api/xpub.go @@ -10,11 +10,9 @@ import ( "github.com/golang/glog" "github.com/juju/errors" - - ethcommon "github.com/ethereum/go-ethereum/common" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/db" - "github.com/syscoin/syscoinwire/syscoin/wire" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" ) const defaultAddressesGap = 20 @@ -24,6 +22,8 @@ const txInput = 1 const txOutput = 2 const xpubCacheExpirationSeconds = 3600 +const xpubCacheMaxEntries = 128 +const maxXpubAddressDerivations = (maxAddressesGap + 1) * 2 var cachedXpubs map[string]xpubData var cachedXpubsMux sync.Mutex @@ -52,7 +52,7 @@ func (a xpubTxids) Less(i, j int) bool { type xpubAddress struct { addrDesc bchain.AddressDescriptor - balance *bchain.AddrBalance + balance *db.AddrBalance txs uint32 maxHeight uint32 complete bool @@ -69,8 +69,8 @@ type xpubData struct { txCountEstimate uint32 sentSat big.Int balanceSat big.Int + mergedTxids xpubTxids addresses [][]xpubAddress - Tokens map[string]*bchain.AssetBalance `json:"tokens,omitempty"` } func (w *Worker) initXpubCache() { @@ -88,9 +88,18 @@ func (w *Worker) initXpubCache() { } func (w *Worker) evictXpubCacheItems() { + now := time.Now().Unix() cachedXpubsMux.Lock() - defer cachedXpubsMux.Unlock() - threshold := time.Now().Unix() - xpubCacheExpirationSeconds + count := evictXpubCacheItemsLocked(now) + cacheSize := len(cachedXpubs) + cachedXpubsMux.Unlock() + + w.metrics.XPubCacheSize.Set(float64(cacheSize)) + glog.Info("Evicted ", count, " items from xpub cache, cache size ", cacheSize) +} + +func evictXpubCacheItemsLocked(now int64) int { + threshold := now - xpubCacheExpirationSeconds count := 0 for k, v := range cachedXpubs { if v.accessed < threshold { @@ -98,24 +107,51 @@ func (w *Worker) evictXpubCacheItems() { count++ } } - w.metrics.XPubCacheSize.Set(float64(len(cachedXpubs))) - glog.Info("Evicted ", count, " items from xpub cache, cache size ", len(cachedXpubs)) + return count + trimXpubCacheItemsLocked() } -func (w *Worker) xpubGetAddressTxids(addrDesc bchain.AddressDescriptor, mempool bool, fromHeight, toHeight uint32, filter *AddressFilter, maxResults int) ([]xpubTxid, bool, error) { +func trimXpubCacheItemsLocked() int { + if len(cachedXpubs) <= xpubCacheMaxEntries { + return 0 + } + type cacheEntry struct { + key string + accessed int64 + } + entries := make([]cacheEntry, 0, len(cachedXpubs)) + for k, v := range cachedXpubs { + entries = append(entries, cacheEntry{key: k, accessed: v.accessed}) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].accessed == entries[j].accessed { + return entries[i].key < entries[j].key + } + return entries[i].accessed < entries[j].accessed + }) + count := len(cachedXpubs) - xpubCacheMaxEntries + for i := 0; i < count; i++ { + delete(cachedXpubs, entries[i].key) + } + return count +} + +func validateXpubScanLimits(xd *bchain.XpubDescriptor, gap int) error { + if len(xd.ChangeIndexes) > bchain.MaxXpubChangeIndexes { + return errors.Errorf("Xpub descriptor change index count %d exceeds limit %d", len(xd.ChangeIndexes), bchain.MaxXpubChangeIndexes) + } + derivations := len(xd.ChangeIndexes) * gap + if derivations > maxXpubAddressDerivations { + return errors.Errorf("Xpub descriptor scan size %d exceeds limit %d", derivations, maxXpubAddressDerivations) + } + return nil +} + +func (w *Worker) xpubGetAddressTxids(addrDesc bchain.AddressDescriptor, mempool bool, fromHeight, toHeight uint32, maxResults int) ([]xpubTxid, bool, error) { var err error complete := true txs := make([]xpubTxid, 0, 4) - contract := uint64(0) - if len(filter.Contract) > 0 { - contract, err = strconv.ParseUint(filter.Contract, 10, 64) - if err != nil { - return nil, false, err - } - } var callback db.GetTransactionsCallback - callback = func(txid string, height uint32, assetGuids []uint64, indexes []int32) error { - foundAsset := contract == 0 + callback = func(txid string, height uint32, indexes []int32) error { // take all txs in the last found block even if it exceeds maxResults if len(txs) >= maxResults && txs[len(txs)-1].height != height { complete = false @@ -129,17 +165,7 @@ func (w *Worker) xpubGetAddressTxids(addrDesc bchain.AddressDescriptor, mempool inputOutput |= txOutput } } - if !foundAsset { - for _, assetGuid := range assetGuids { - if contract == assetGuid { - foundAsset = true - break - } - } - } - if foundAsset { - txs = append(txs, xpubTxid{txid, height, inputOutput}) - } + txs = append(txs, xpubTxid{txid, height, inputOutput}) return nil } if mempool { @@ -151,7 +177,7 @@ func (w *Worker) xpubGetAddressTxids(addrDesc bchain.AddressDescriptor, mempool for _, m := range o { if l, found := uniqueTxs[m.Txid]; !found { l = len(txs) - callback(m.Txid, 0, []uint64{0}, []int32{m.Vout}) + callback(m.Txid, 0, []int32{m.Vout}) if len(txs) > l { uniqueTxs[m.Txid] = l } @@ -164,7 +190,7 @@ func (w *Worker) xpubGetAddressTxids(addrDesc bchain.AddressDescriptor, mempool } } } else { - err = w.db.GetAddrDescTransactions(addrDesc, fromHeight, toHeight, filter.AssetsMask, callback) + err = w.db.GetAddrDescTransactions(addrDesc, fromHeight, toHeight, callback) if err != nil { return nil, false, err } @@ -172,15 +198,37 @@ func (w *Worker) xpubGetAddressTxids(addrDesc bchain.AddressDescriptor, mempool return txs, complete, nil } -func (w *Worker) xpubCheckAndLoadTxids(ad *xpubAddress, filter *AddressFilter, maxHeight uint32, maxResults int) error { +func isUnfilteredXpubTxidFilter(filter *AddressFilter) bool { + return filter == nil || filter.FromHeight == 0 && filter.ToHeight == 0 && filter.Vout == AddressFilterVoutOff && filter.AssetsMask == bchain.AllMask +} + +func mergeXpubTxids(data *xpubData) xpubTxids { + txcMap := make(map[string]struct{}, data.txCountEstimate) + txc := make(xpubTxids, 0, data.txCountEstimate) + for _, da := range data.addresses { + for i := range da { + for _, txid := range da[i].txids { + if _, foundTx := txcMap[txid.txid]; foundTx { + continue + } + txcMap[txid.txid] = struct{}{} + txc = append(txc, txid) + } + } + } + sort.Stable(txc) + return txc +} + +func (w *Worker) xpubCheckAndLoadTxids(ad *xpubAddress, maxHeight uint32, maxResults int) (bool, error) { // skip if not used if ad.balance == nil { - return nil + return false, nil } // if completely loaded, check if there are not some new txs and load if necessary if ad.complete { if ad.balance.Txs != ad.txs { - newTxids, _, err := w.xpubGetAddressTxids(ad.addrDesc, false, ad.maxHeight+1, maxHeight, filter, maxInt) + newTxids, _, err := w.xpubGetAddressTxids(ad.addrDesc, false, ad.maxHeight+1, maxHeight, maxInt) if err == nil { ad.txids = append(newTxids, ad.txids...) ad.maxHeight = maxHeight @@ -189,14 +237,14 @@ func (w *Worker) xpubCheckAndLoadTxids(ad *xpubAddress, filter *AddressFilter, m glog.Warning("xpubCheckAndLoadTxids inconsistency ", ad.addrDesc, ", ad.txs=", ad.txs, ", ad.balance.Txs=", ad.balance.Txs) } } - return err + return err == nil, err } - return nil + return false, nil } // load all txids to get paging correctly - newTxids, complete, err := w.xpubGetAddressTxids(ad.addrDesc, false, 0, maxHeight, filter, maxInt) + newTxids, complete, err := w.xpubGetAddressTxids(ad.addrDesc, false, 0, maxHeight, maxInt) if err != nil { - return err + return false, err } ad.txids = newTxids ad.complete = complete @@ -207,40 +255,27 @@ func (w *Worker) xpubCheckAndLoadTxids(ad *xpubAddress, filter *AddressFilter, m glog.Warning("xpubCheckAndLoadTxids inconsistency ", ad.addrDesc, ", ad.txs=", ad.txs, ", ad.balance.Txs=", ad.balance.Txs) } } - return nil + return true, nil } func (w *Worker) xpubDerivedAddressBalance(data *xpubData, ad *xpubAddress) (bool, error) { var err error - if ad.balance, err = w.db.GetAddrDescBalance(ad.addrDesc, bchain.AddressBalanceDetailUTXO); err != nil { + if ad.balance, err = w.db.GetAddrDescBalance(ad.addrDesc, db.AddressBalanceDetailUTXO); err != nil { return false, err } if ad.balance != nil { data.txCountEstimate += ad.balance.Txs data.sentSat.Add(&data.sentSat, &ad.balance.SentSat) data.balanceSat.Add(&data.balanceSat, &ad.balance.BalanceSat) - if ad.balance.AssetBalances != nil { - if data.Tokens == nil { - data.Tokens = map[string]*bchain.AssetBalance{} - } - for guid, assetBalance := range ad.balance.AssetBalances { - assetGuid := strconv.FormatUint(guid, 10) - bhaToken, ok := data.Tokens[assetGuid] - if !ok { - bhaToken = &bchain.AssetBalance{Transfers: 0, SentSat: big.NewInt(0), BalanceSat: big.NewInt(0)} - data.Tokens[assetGuid] = bhaToken - } - bhaToken.Transfers += assetBalance.Transfers - bhaToken.SentSat.Add(bhaToken.SentSat, assetBalance.SentSat) - bhaToken.BalanceSat.Add(bhaToken.BalanceSat, assetBalance.BalanceSat) - } - } return true, nil } return false, nil } -func (w *Worker) xpubScanAddresses(xd *bchain.XpubDescriptor, data *xpubData, addresses []xpubAddress, gap int, change uint32, minDerivedIndex int, fork bool) (int, []xpubAddress, error) { +func (w *Worker) xpubScanAddresses(xd *bchain.XpubDescriptor, data *xpubData, addresses []xpubAddress, gap int, change uint32, minDerivedIndex int, fork bool, derivedBefore int) (int, []xpubAddress, error) { + if total := derivedBefore + len(addresses); total > maxXpubAddressDerivations { + return 0, nil, errors.Errorf("Xpub descriptor scan size %d exceeds limit %d", total, maxXpubAddressDerivations) + } // rescan known addresses lastUsed := 0 for i := range addresses { @@ -268,6 +303,9 @@ func (w *Worker) xpubScanAddresses(xd *bchain.XpubDescriptor, data *xpubData, ad if to < minDerivedIndex { to = minDerivedIndex } + if total := derivedBefore + to; total > maxXpubAddressDerivations { + return 0, nil, errors.Errorf("Xpub descriptor scan size %d exceeds limit %d", total, maxXpubAddressDerivations) + } descriptors, err := w.chainParser.DeriveAddressDescriptorsFromTo(xd, change, uint32(from), uint32(to)) if err != nil { return 0, nil, err @@ -288,13 +326,8 @@ func (w *Worker) xpubScanAddresses(xd *bchain.XpubDescriptor, data *xpubData, ad return lastUsed, addresses, nil } -func (w *Worker) tokenFromXpubAddress(data *xpubData, ad *xpubAddress, changeIndex int, index int, option AccountDetails) (bchain.Tokens, error) { +func (w *Worker) tokenFromXpubAddress(data *xpubData, ad *xpubAddress, changeIndex int, index int, option AccountDetails) Token { a, _, _ := w.chainParser.GetAddressesFromAddrDesc(ad.addrDesc) - numAssetBalances := 0 - if ad.balance != nil { - numAssetBalances = len(ad.balance.AssetBalances) - } - tokens := make(bchain.Tokens, 0, numAssetBalances) var address string if len(a) > 0 { address = a[0] @@ -307,40 +340,46 @@ func (w *Worker) tokenFromXpubAddress(data *xpubData, ad *xpubAddress, changeInd balance = &ad.balance.BalanceSat totalSent = &ad.balance.SentSat totalReceived = ad.balance.ReceivedSat() - for k, v := range ad.balance.AssetBalances { - dbAsset, errAsset := w.db.GetAsset(k, nil) - if errAsset != nil { - dbAsset = &bchain.Asset{Transactions: 0, AssetObj: wire.AssetType{Symbol: []byte(strconv.FormatUint(k, 10)), Precision: 8}} - } - totalAssetReceived := bchain.ReceivedSatFromBalances(v.BalanceSat, v.SentSat) - assetGuid := strconv.FormatUint(uint64(k), 10) - tokens = append(tokens, &bchain.Token{ - Type: bchain.SPTTokenType, - Name: address, - Decimals: int(dbAsset.AssetObj.Precision), - Symbol: string(dbAsset.AssetObj.Symbol), - BalanceSat: (*bchain.Amount)(v.BalanceSat), - TotalReceivedSat: (*bchain.Amount)(totalAssetReceived), - TotalSentSat: (*bchain.Amount)(v.SentSat), - Path: fmt.Sprintf("%s/%d/%d", data.basePath, changeIndex, index), - AssetGuid: assetGuid, - Transfers: v.Transfers, - }) - } - sort.Sort(tokens) - } - } - // for base token - tokens = append(tokens, &bchain.Token{ - Type: bchain.XPUBAddressTokenType, + } + } + return Token{ + // Deprecated: Use Standard instead. + Type: bchain.XPUBAddressStandard, + Standard: bchain.XPUBAddressStandard, Name: address, Decimals: w.chainParser.AmountDecimals(), - BalanceSat: (*bchain.Amount)(balance), - TotalReceivedSat: (*bchain.Amount)(totalReceived), - TotalSentSat: (*bchain.Amount)(totalSent), - Transfers: uint32(transfers), + BalanceSat: (*Amount)(balance), + TotalReceivedSat: (*Amount)(totalReceived), + TotalSentSat: (*Amount)(totalSent), + Transfers: transfers, Path: fmt.Sprintf("%s/%d/%d", data.basePath, changeIndex, index), - }) + } +} + +// SYSCOIN: assetTokensFromXpubAddress returns SPT balances grouped by derived +// XPUB address. The base SYS address token remains handled by tokenFromXpubAddress. +func (w *Worker) assetTokensFromXpubAddress(data *xpubData, ad *xpubAddress, changeIndex int, index int, option AccountDetails, mempool map[string]*syscoinTokenMempoolInfo) ([]Token, error) { + if option < AccountDetailsTokenBalances { + return nil, nil + } + a, _, _ := w.chainParser.GetAddressesFromAddrDesc(ad.addrDesc) + var address string + if len(a) > 0 { + address = a[0] + } + var balances map[uint64]*bchain.AssetBalance + if ad.balance != nil { + balances = ad.balance.AssetBalances + } + tokens, _, err := w.getSyscoinAddressAssetTokens(address, balances, mempool) + if err != nil { + return nil, err + } + path := fmt.Sprintf("%s/%d/%d", data.basePath, changeIndex, index) + for i := range tokens { + tokens[i].Path = path + } + sort.Sort(Tokens(tokens)) return tokens, nil } @@ -388,6 +427,9 @@ func (w *Worker) getXpubData(xd *bchain.XpubDescriptor, page int, txsOnPage int, } // gap is increased one as there must be gap of empty addresses before the derivation is stopped gap++ + if err := validateXpubScanLimits(xd, gap); err != nil { + return nil, 0, false, err + } var processedHash string cachedXpubsMux.Lock() data, inCache := cachedXpubs[xd.XpubDescriptor] @@ -428,39 +470,59 @@ func (w *Worker) getXpubData(xd *bchain.XpubDescriptor, page int, txsOnPage int, data.balanceSat = *new(big.Int) data.sentSat = *new(big.Int) data.txCountEstimate = 0 - data.Tokens = nil + data.mergedTxids = nil var minDerivedIndex int + totalDerived := 0 for i, change := range xd.ChangeIndexes { - minDerivedIndex, data.addresses[i], err = w.xpubScanAddresses(xd, &data, data.addresses[i], gap, change, minDerivedIndex, fork) + minDerivedIndex, data.addresses[i], err = w.xpubScanAddresses(xd, &data, data.addresses[i], gap, change, minDerivedIndex, fork, totalDerived) if err != nil { return nil, 0, inCache, err } + totalDerived += len(data.addresses[i]) } } if option >= AccountDetailsTxidHistory { + txidsChanged := false for _, da := range data.addresses { for i := range da { - if err = w.xpubCheckAndLoadTxids(&da[i], filter, bestheight, (page+1)*txsOnPage); err != nil { + changed := false + if changed, err = w.xpubCheckAndLoadTxids(&da[i], bestheight, (page+1)*txsOnPage); err != nil { return nil, 0, inCache, err } + txidsChanged = txidsChanged || changed } } + if txidsChanged { + data.mergedTxids = nil + } + if isUnfilteredXpubTxidFilter(filter) && data.mergedTxids == nil { + data.mergedTxids = mergeXpubTxids(&data) + } } } data.accessed = time.Now().Unix() cachedXpubsMux.Lock() + if cachedXpubs == nil { + cachedXpubs = make(map[string]xpubData) + } cachedXpubs[xd.XpubDescriptor] = data + trimXpubCacheItemsLocked() + cacheSize := len(cachedXpubs) cachedXpubsMux.Unlock() + w.metrics.XPubCacheSize.Set(float64(cacheSize)) return &data, bestheight, inCache, nil } // GetXpubAddress computes address value and gets transactions for given address -func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option AccountDetails, filter *AddressFilter, gap int) (*Address, error) { +func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option AccountDetails, filter *AddressFilter, gap int, secondaryCoin string) (*Address, error) { start := time.Now() page-- if page < 0 { page = 0 } + if filter == nil { + filter = &AddressFilter{Vout: AddressFilterVoutOff, AssetsMask: bchain.AllMask} + } type mempoolMap struct { tx *Tx inputOutput byte @@ -484,11 +546,9 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc if err != nil { return nil, err } - // Track SPT mempool deltas per derived address to avoid cross-address consumption - perAddrAssetMempool := map[string]map[string]*TokenMempoolInfo{} // setup filtering of txids var txidFilter func(txid *xpubTxid, ad *xpubAddress) bool - if !(filter.FromHeight == 0 && filter.ToHeight == 0 && filter.Vout == AddressFilterVoutOff) { + if !(filter.FromHeight == 0 && filter.ToHeight == 0 && filter.Vout == AddressFilterVoutOff && filter.AssetsMask == bchain.AllMask) { toHeight := maxUint32 if filter.ToHeight != 0 { toHeight = filter.ToHeight @@ -503,33 +563,65 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc return false } } + if filter.AssetsMask != bchain.AllMask { + ta, err := w.db.GetTxAddresses(txid.txid) + if err != nil || ta == nil { + glog.Warningf("GetTxAddresses xpub assetMask filter %v: %v", txid.txid, err) + return false + } + mask := assetMaskFromVersion(w.chainParser, ta.Version) + if mask != bchain.AllMask && uint32(filter.AssetsMask)&uint32(mask) != uint32(mask) { + return false + } + } return true } filtered = true } + xpubMempoolTxidFilter := func(txid *xpubTxid) bool { + if filter.FromHeight != 0 { + return false + } + if filter.Vout != AddressFilterVoutOff { + if filter.Vout == AddressFilterVoutInputs && txid.inputOutput&txInput == 0 || + filter.Vout == AddressFilterVoutOutputs && txid.inputOutput&txOutput == 0 { + return false + } + } + return true + } + addresses := w.newAddressesMapForAliases() + xpubAssetMempool := make(map[string]map[string]*syscoinTokenMempoolInfo) // SYSCOIN // process mempool, only if ToHeight is not specified if filter.ToHeight == 0 && !filter.OnlyConfirmed { txmMap = make(map[string]*Tx) + countedMempoolTxs := make(map[string]struct{}) + addedMempoolEntries := make(map[string]struct{}) mempoolEntries := make(bchain.MempoolTxidEntries, 0) for _, da := range data.addresses { for i := range da { ad := &da[i] - // prepare per-address mempool map - addrKey := string(ad.addrDesc) - localAssetMempool, exists := perAddrAssetMempool[addrKey] - if !exists { - localAssetMempool = map[string]*TokenMempoolInfo{} - perAddrAssetMempool[addrKey] = localAssetMempool - } - newTxids, _, err := w.xpubGetAddressTxids(ad.addrDesc, true, 0, 0, filter, maxInt) + newTxids, _, err := w.xpubGetAddressTxids(ad.addrDesc, true, 0, 0, maxInt) if err != nil { return nil, err } for _, txid := range newTxids { // the same tx can have multiple addresses from the same xpub, get it from backend it only once tx, foundTx := txmMap[txid.txid] - if !foundTx { - tx, err = w.GetTransaction(txid.txid, false, true) + needTx := option != AccountDetailsBasic || filter.AssetsMask != bchain.AllMask + if option == AccountDetailsBasic && !needTx { + // Basic detail: skip per-tx loading. Count unique mempool txids + // across derived addresses; the count may transiently include + // entries that have just been confirmed but not yet evicted. + if _, counted := countedMempoolTxs[txid.txid]; !counted { + txmMap[txid.txid] = nil + countedMempoolTxs[txid.txid] = struct{}{} + unconfirmedTxs++ + } + continue + } + if !foundTx || tx == nil { + tx, err = w.getTransaction(txid.txid, false, true, addresses) // mempool transaction may fail if err != nil || tx == nil { glog.Warning("GetTransaction in mempool: ", err) @@ -539,14 +631,34 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc } // skip already confirmed txs, mempool may be out of sync if tx.Confirmations == 0 { - if !foundTx { + if filter.AssetsMask != bchain.AllMask { + mask := assetMaskFromVersion(w.chainParser, tx.Version) + if mask != bchain.AllMask && uint32(filter.AssetsMask)&uint32(mask) != uint32(mask) { + continue + } + } + if _, counted := countedMempoolTxs[txid.txid]; !counted { + countedMempoolTxs[txid.txid] = struct{}{} unconfirmedTxs++ } - // accumulate base coin unconfirmed delta and per-address asset deltas - uBalSat.Add(&uBalSat, tx.getAddrVoutValue(ad.addrDesc, localAssetMempool)) - uBalSat.Sub(&uBalSat, tx.getAddrVinValue(ad.addrDesc, localAssetMempool)) + uBalSat.Add(&uBalSat, tx.getAddrVoutValue(ad.addrDesc)) + uBalSat.Sub(&uBalSat, tx.getAddrVinValue(ad.addrDesc)) + if option > AccountDetailsBasic { + addrKey := string(ad.addrDesc) + assetMempool := xpubAssetMempool[addrKey] + if assetMempool == nil { + assetMempool = make(map[string]*syscoinTokenMempoolInfo) + xpubAssetMempool[addrKey] = assetMempool + } + tx.addAddrVoutAssetMempool(ad.addrDesc, assetMempool) + tx.addAddrVinAssetMempool(ad.addrDesc, assetMempool) + } // mempool txs are returned only on the first page, uniquely and filtered - if page == 0 && !foundTx && (txidFilter == nil || txidFilter(&txid, ad)) { + if page == 0 && xpubMempoolTxidFilter(&txid) { + if _, added := addedMempoolEntries[txid.txid]; added { + continue + } + addedMempoolEntries[txid.txid] = struct{}{} mempoolEntries = append(mempoolEntries, bchain.MempoolTxidEntry{Txid: txid.txid, Time: uint32(tx.Blocktime)}) } } @@ -564,30 +676,35 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc } } if option >= AccountDetailsTxidHistory { - txcMap := make(map[string]bool) - txc = make(xpubTxids, 0, 32) - for _, da := range data.addresses { - for i := range da { - ad := &da[i] - for _, txid := range ad.txids { - added, foundTx := txcMap[txid.txid] - // count txs regardless of filter but only once - if !foundTx { - txCount++ - } - // add tx only once - if !added { - add := txidFilter == nil || txidFilter(&txid, ad) - txcMap[txid.txid] = add - if add { - txc = append(txc, txid) + if txidFilter == nil { + // Shared with xpubData cache; do not mutate in this request path. + txc = data.mergedTxids + if txc == nil { + txc = mergeXpubTxids(data) + } + txCount = len(txc) + } else { + txcMap := make(map[string]bool) + txc = make(xpubTxids, 0, 32) + for _, da := range data.addresses { + for i := range da { + ad := &da[i] + for _, txid := range ad.txids { + added := txcMap[txid.txid] + // add tx only once + if !added { + add := txidFilter(&txid, ad) + txcMap[txid.txid] = add + if add { + txc = append(txc, txid) + } } } } } + sort.Stable(txc) + txCount = len(txcMap) } - sort.Stable(txc) - txCount = len(txcMap) totalResults := txCount if filtered { totalResults = -1 @@ -607,7 +724,7 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc if option == AccountDetailsTxidHistory { txids = append(txids, xpubTxid.txid) } else { - tx, err := w.txFromTxid(xpubTxid.txid, bestheight, option, nil) + tx, err := w.txFromTxid(xpubTxid.txid, bestheight, option, nil, addresses) if err != nil { return nil, err } @@ -617,94 +734,43 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc } else { txCount = int(data.txCountEstimate) } + addrTxCount := int(data.txCountEstimate) usedTokens := 0 - usedAssetTokens := 0 - var tokens bchain.Tokens - var tokensAsset bchain.Tokens + usedAssetTokens := 0 // SYSCOIN + var tokens []Token + var tokensAsset []Token // SYSCOIN var xpubAddresses map[string]struct{} if option > AccountDetailsBasic { - tokens = make(bchain.Tokens, 0, 4) - tokensAsset = make(bchain.Tokens, 0, 4) + tokens = make([]Token, 0, 4) + tokensAsset = make([]Token, 0, 4) // SYSCOIN xpubAddresses = make(map[string]struct{}) } for ci, da := range data.addresses { for i := range da { ad := &da[i] - if ad.balance != nil && ad.balance.Txs > 0 { - if ad.balance.AssetBalances != nil && len(ad.balance.AssetBalances) > 0 { - usedAssetTokens++ - } + if ad.balance != nil { usedTokens++ } if option > AccountDetailsBasic { - tokensXPub, errXpub := w.tokenFromXpubAddress(data, ad, ci, i, option) - if errXpub != nil { - return nil, errXpub + 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) { + tokens = append(tokens, token) } - if len(tokensXPub) > 0 { - for _, token := range tokensXPub { - if token != nil { - if filter.TokensToReturn == TokensToReturnDerived || - filter.TokensToReturn == TokensToReturnUsed && ad.balance != nil || - filter.TokensToReturn == TokensToReturnNonzeroBalance && token.BalanceSat != nil && token.BalanceSat.AsInt64() != 0 { - if token.Type != bchain.XPUBAddressTokenType { - // attach per-address mempool delta if present - localAssetMempool := perAddrAssetMempool[string(ad.addrDesc)] - if localAssetMempool != nil { - if mempoolAsset, ok := localAssetMempool[token.AssetGuid]; ok && mempoolAsset.Used == false { - token.UnconfirmedBalanceSat = (*bchain.Amount)(mempoolAsset.ValueSat) - token.UnconfirmedTransfers = mempoolAsset.UnconfirmedTxs - mempoolAsset.Used = true - localAssetMempool[token.AssetGuid] = mempoolAsset - } - } - tokensAsset = append(tokensAsset, token) - } else { - tokens = append(tokens, token) - } - } - xpubAddresses[token.Name] = struct{}{} - } - } + xpubAddresses[token.Name] = struct{}{} + // SYSCOIN: include SPT balances per derived XPUB address. + assetTokens, err := w.assetTokensFromXpubAddress(data, ad, int(xd.ChangeIndexes[ci]), i, option, xpubAssetMempool[string(ad.addrDesc)]) + if err != nil { + return nil, err } - if option > AccountDetailsBasic { - - localAssetMempool := perAddrAssetMempool[string(ad.addrDesc)] - if len(localAssetMempool) > 0 { - a, _, _ := w.chainParser.GetAddressesFromAddrDesc(ad.addrDesc) - var address string - if len(a) > 0 { - address = a[0] - } - for k, v := range localAssetMempool { - // if already used we show the unconfirmed amounts in token above, otherwise we add a new token with some cleared values as the token is being sent to a new address - if v.Used == true { - continue - } - assetGuid, err := strconv.ParseUint(k, 10, 64) - if err != nil { - return nil, err - } - dbAsset, errAsset := w.db.GetAsset(assetGuid, nil) - if errAsset != nil { - dbAsset = &bchain.Asset{Transactions: 0, AssetObj: wire.AssetType{Symbol: []byte(k), Precision: 8}} - } - tokensAsset = append(tokensAsset, &bchain.Token{ - Type: bchain.SPTTokenType, - Name: address, - Decimals: int(dbAsset.AssetObj.Precision), - Symbol: string(dbAsset.AssetObj.Symbol), - BalanceSat: &bchain.Amount{}, - UnconfirmedBalanceSat: (*bchain.Amount)(v.ValueSat), - TotalReceivedSat: &bchain.Amount{}, - TotalSentSat: &bchain.Amount{}, - AssetGuid: k, - Transfers: 0, - UnconfirmedTransfers: v.UnconfirmedTxs, - }) - v.Used = true - localAssetMempool[k] = v - } + usedAssetTokens += len(assetTokens) + for _, assetToken := range assetTokens { + if filter.TokensToReturn == TokensToReturnDerived || + filter.TokensToReturn == TokensToReturnUsed && ad.balance != nil || + filter.TokensToReturn == TokensToReturnNonzeroBalance && (assetToken.BalanceSat != nil && !IsZeroBigInt((*big.Int)(assetToken.BalanceSat)) || + assetToken.UnconfirmedBalanceSat != nil && !IsZeroBigInt((*big.Int)(assetToken.UnconfirmedBalanceSat))) { + tokensAsset = append(tokensAsset, assetToken) } } } @@ -713,50 +779,63 @@ func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option Acc setIsOwnAddresses(txs, xpubAddresses) var totalReceived big.Int totalReceived.Add(&data.balanceSat, &data.sentSat) + + var secondaryValue float64 + if secondaryCoin != "" { + 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] + if found { + secondaryRate := float64(r) + secondaryValue = secondaryRate * balance + } + } + } + + var unconfirmedBalanceSat *Amount + if option > AccountDetailsBasic { + unconfirmedBalanceSat = (*Amount)(&uBalSat) + } addr := Address{ Paging: pg, AddrStr: xpub, - BalanceSat: (*bchain.Amount)(&data.balanceSat), - TotalReceivedSat: (*bchain.Amount)(&totalReceived), - TotalSentSat: (*bchain.Amount)(&data.sentSat), + BalanceSat: (*Amount)(&data.balanceSat), + TotalReceivedSat: (*Amount)(&totalReceived), + TotalSentSat: (*Amount)(&data.sentSat), Txs: txCount, - UnconfirmedBalanceSat: (*bchain.Amount)(&uBalSat), + AddrTxCount: addrTxCount, + UnconfirmedBalanceSat: unconfirmedBalanceSat, UnconfirmedTxs: unconfirmedTxs, Transactions: txs, Txids: txids, UsedTokens: usedTokens, + UsedAssetTokens: usedAssetTokens, // SYSCOIN Tokens: tokens, + TokensAsset: tokensAsset, // SYSCOIN + SecondaryValue: secondaryValue, XPubAddresses: xpubAddresses, + AddressAliases: w.getAddressAliases(addresses), } - if usedAssetTokens > 0 { - addr.UsedAssetTokens = usedAssetTokens - } - if len(tokensAsset) > 0 { - addr.TokensAsset = tokensAsset - } - glog.Info("GetXpubAddress ", xpub[:xpubLogPrefix], ", cache ", inCache, ", ", txCount, " txs, ", time.Since(start)) + glog.V(1).Info("GetXpubAddress ", xpub[:xpubLogPrefix], ", cache ", inCache, ", ", txCount, " txs, ", time.Since(start)) return &addr, nil } // GetXpubUtxo returns unspent outputs for given xpub func (w *Worker) GetXpubUtxo(xpub string, onlyConfirmed bool, gap int) (Utxos, error) { start := time.Now() - var utxoRes Utxos xd, err := w.chainParser.ParseXpub(xpub) if err != nil { - return utxoRes, err + return nil, err } - data, _, inCache, err := w.getXpubData(xd, 0, 1, AccountDetailsBasic, &AddressFilter{ + data, bestheight, inCache, err := w.getXpubData(xd, 0, 1, AccountDetailsBasic, &AddressFilter{ Vout: AddressFilterVoutOff, OnlyConfirmed: onlyConfirmed, - AssetsMask: bchain.AllMask, }, gap) if err != nil { - return utxoRes, err + return nil, err } - utxoRes.Utxos = make([]Utxo, 0, 8) - assets := make([]*AssetSpecific, 0, 0) - assetsMap := make(map[uint64]bool, 0) + r := make(Utxos, 0, 8) for ci, da := range data.addresses { for i := range da { ad := &da[i] @@ -767,67 +846,32 @@ func (w *Worker) GetXpubUtxo(xpub string, onlyConfirmed bool, gap int) (Utxos, e } onlyMempool = true } - utxos, err := w.getAddrDescUtxo(ad.addrDesc, ad.balance, onlyConfirmed, onlyMempool) + utxos, err := w.getAddrDescUtxo(ad.addrDesc, ad.balance, onlyConfirmed, onlyMempool, &bestheight) if err != nil { - return utxoRes, err + return nil, err } if len(utxos) > 0 { - txs, errXpub := w.tokenFromXpubAddress(data, ad, ci, i, AccountDetailsTokens) - if errXpub != nil { - return utxoRes, errXpub + t := w.tokenFromXpubAddress(data, ad, ci, i, AccountDetailsTokens) + for j := range utxos { + a := &utxos[j] + a.Address = t.Name + a.Path = t.Path } - if len(txs) > 0 { - for _, t := range txs { - for j := range utxos { - a := &utxos[j] - a.Address = t.Name - a.Path = t.Path - } - } - // add applicable assets to UTXO - for j := range utxos { - a := &utxos[j] - if a.AssetInfo != nil { - assetGuid, err := strconv.ParseUint(a.AssetInfo.AssetGuid, 10, 64) - if err != nil { - return utxoRes, err - } - dbAsset, errAsset := w.db.GetAsset(assetGuid, nil) - if errAsset != nil { - dbAsset = &bchain.Asset{Transactions: 0, AssetObj: wire.AssetType{Symbol: []byte(a.AssetInfo.AssetGuid), Precision: 8}} - } - // add unique assets - var _, ok = assetsMap[assetGuid] - if ok { - continue - } - assetsMap[assetGuid] = true - assetDetails := &AssetSpecific{ - AssetGuid: strconv.FormatUint(assetGuid, 10), - Symbol: string(dbAsset.AssetObj.Symbol), - Contract: ethcommon.BytesToAddress(dbAsset.AssetObj.Contract).Hex(), - TotalSupply: (*bchain.Amount)(big.NewInt(dbAsset.AssetObj.TotalSupply)), - MaxSupply: (*bchain.Amount)(big.NewInt(dbAsset.AssetObj.MaxSupply)), - Decimals: int(dbAsset.AssetObj.Precision), - } - assets = append(assets, assetDetails) - } - } - } - utxoRes.Utxos = append(utxoRes.Utxos, utxos...) + r = append(r, utxos...) } } } - sort.Stable(utxoRes) - if len(assets) > 0 { - utxoRes.Assets = assets - } - glog.Info("GetXpubUtxo ", xpub[:xpubLogPrefix], ", cache ", inCache, ", ", len(utxoRes.Utxos), " utxos, ", len(assets), " assets, ", time.Since(start)) - return utxoRes, nil + sort.Stable(r) + glog.V(1).Info("GetXpubUtxo ", xpub[:xpubLogPrefix], ", cache ", inCache, ", ", len(r), " utxos, ", time.Since(start)) + return r, nil } -// GetXpubBalanceHistory returns history of balance for given xpub -func (w *Worker) GetXpubBalanceHistory(xpub string, fromTimestamp, toTimestamp int64, currencies []string, gap int, groupBy uint32) (BalanceHistories, error) { +// GetXpubBalanceHistory returns history of balance for given xpub. maxTxs bounds +// how many transactions in the requested range (summed across the derived +// addresses) may be aggregated (0 = unlimited); the caller supplies the +// transport-specific cap (WS vs REST). transport labels the emitted metrics with +// the serving surface. +func (w *Worker) GetXpubBalanceHistory(xpub string, fromTimestamp, toTimestamp int64, currencies []string, gap int, groupBy uint32, maxTxs int, transport string) (BalanceHistories, error) { bhs := make(BalanceHistories, 0) start := time.Now() fromUnix, fromHeight, toUnix, toHeight := w.balanceHistoryHeightsFromTo(fromTimestamp, toTimestamp) @@ -838,12 +882,13 @@ func (w *Worker) GetXpubBalanceHistory(xpub string, fromTimestamp, toTimestamp i if err != nil { return nil, err } - data, _, inCache, err := w.getXpubData(xd, 0, 1, AccountDetailsTxidHistory, &AddressFilter{ + // Load only the derived addresses and their balances (cheap, shared cache), not + // AccountDetailsTxidHistory -- that loads every address's full txid history + // (unbounded, ignoring from/to) before any cap could reject it. Instead query each + // address's txids within the height range below, bounded to maxTxs+1. + data, _, inCache, err := w.getXpubData(xd, 0, 1, AccountDetailsBasic, &AddressFilter{ Vout: AddressFilterVoutOff, OnlyConfirmed: true, - FromHeight: fromHeight, - ToHeight: toHeight, - AssetsMask: bchain.AllMask, }, gap) if err != nil { return nil, err @@ -854,26 +899,74 @@ func (w *Worker) GetXpubBalanceHistory(xpub string, fromTimestamp, toTimestamp i selfAddrDesc[string(da[i].addrDesc)] = struct{}{} } } + // Bound the work: each transaction costs a DB read below, so load at most one + // more than the cap across all derived addresses (overflow detectable), then + // reject rather than silently truncate. + remaining := maxInt + if maxTxs > 0 { + remaining = maxTxs + 1 + } + type addrTxids struct { + addrDesc bchain.AddressDescriptor + txids []string + } + var loaded []addrTxids + total := 0 + rangeFilter := &AddressFilter{Vout: AddressFilterVoutOff, FromHeight: fromHeight, ToHeight: toHeight} for _, da := range data.addresses { for i := range da { ad := &da[i] - txids := ad.txids - for txi := len(txids) - 1; txi >= 0; txi-- { - bh, err := w.balanceHistoryForTxid(ad.addrDesc, txids[txi].txid, fromUnix, toUnix, selfAddrDesc) - if err != nil { - return nil, err - } - if bh != nil { - bhs = append(bhs, *bh) + if ad.balance == nil { + continue + } + txids, err := w.getAddressTxids(ad.addrDesc, false, rangeFilter, remaining) + if err != nil { + return nil, err + } + if len(txids) == 0 { + continue + } + loaded = append(loaded, addrTxids{addrDesc: ad.addrDesc, txids: txids}) + total += len(txids) + if maxTxs > 0 { + if remaining -= len(txids); remaining <= 0 { + break } } } + if maxTxs > 0 && remaining <= 0 { + break + } + } + if w.metrics != nil { + w.metrics.BalanceHistoryTxs.With(common.Labels{"transport": transport, "path": "xpub"}).Observe(float64(total)) + } + if maxTxs > 0 && total > maxTxs { + if w.metrics != nil { + w.metrics.BalanceHistoryCapExceeded.With(common.Labels{"transport": transport, "path": "xpub"}).Inc() + } + return nil, NewAPIError(fmt.Sprintf("balance history for xpub spans more than %d transactions in the requested range; narrow the from/to range", maxTxs), true) + } + for _, at := range loaded { + txids := at.txids + for txi := len(txids) - 1; txi >= 0; txi-- { + bh, err := w.balanceHistoryForTxid(at.addrDesc, txids[txi], fromUnix, toUnix, selfAddrDesc) + if err != nil { + return nil, err + } + if bh != nil { + bhs = append(bhs, *bh) + } + } } 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 } - glog.Info("GetUtxoBalanceHistory ", xpub[:xpubLogPrefix], ", cache ", inCache, ", blocks ", fromHeight, "-", toHeight, ", count ", len(bha), ", ", time.Since(start)) + glog.V(1).Info("GetUtxoBalanceHistory ", xpub[:xpubLogPrefix], ", cache ", inCache, ", blocks ", fromHeight, "-", toHeight, ", count ", len(bha), ", ", time.Since(start)) return bha, nil } diff --git a/api/xpub_test.go b/api/xpub_test.go new file mode 100644 index 0000000000..a0713df1b1 --- /dev/null +++ b/api/xpub_test.go @@ -0,0 +1,99 @@ +//go:build unittest + +package api + +import ( + "fmt" + "testing" + + "github.com/trezor/blockbook/bchain" +) + +func TestValidateXpubScanLimits(t *testing.T) { + if err := validateXpubScanLimits(&bchain.XpubDescriptor{ChangeIndexes: []uint32{0, 1}}, maxAddressesGap+1); err != nil { + t.Fatalf("expected default change indexes at max gap to pass, got %v", err) + } + + changes := make([]uint32, bchain.MaxXpubChangeIndexes+1) + if err := validateXpubScanLimits(&bchain.XpubDescriptor{ChangeIndexes: changes}, defaultAddressesGap+1); err == nil { + t.Fatal("expected change index count above limit to fail") + } + + changes = make([]uint32, 3) + if err := validateXpubScanLimits(&bchain.XpubDescriptor{ChangeIndexes: changes}, maxAddressesGap+1); err == nil { + t.Fatal("expected scan size above limit to fail") + } +} + +func TestTrimXpubCacheItemsLocked(t *testing.T) { + cachedXpubsMux.Lock() + defer cachedXpubsMux.Unlock() + + originalCache := cachedXpubs + defer func() { + cachedXpubs = originalCache + }() + + cachedXpubs = make(map[string]xpubData, xpubCacheMaxEntries+2) + for i := 0; i < xpubCacheMaxEntries+2; i++ { + cachedXpubs[fmt.Sprintf("xpub-%03d", i)] = xpubData{accessed: int64(i)} + } + + if got := trimXpubCacheItemsLocked(); got != 2 { + t.Fatalf("trimXpubCacheItemsLocked() evicted %d entries, want 2", got) + } + if got := len(cachedXpubs); got != xpubCacheMaxEntries { + t.Fatalf("cachedXpubs length = %d, want %d", got, xpubCacheMaxEntries) + } + if _, ok := cachedXpubs["xpub-000"]; ok { + t.Fatal("oldest cache entry was not evicted") + } + if _, ok := cachedXpubs["xpub-001"]; ok { + t.Fatal("second oldest cache entry was not evicted") + } +} + +func TestMergeXpubTxidsDeduplicatesAndSorts(t *testing.T) { + data := &xpubData{ + txCountEstimate: 4, + addresses: [][]xpubAddress{ + { + {txids: xpubTxids{ + {txid: "duplicate", height: 5, inputOutput: txOutput}, + {txid: "newest", height: 7, inputOutput: txOutput}, + }}, + }, + { + {txids: xpubTxids{ + {txid: "duplicate", height: 5, inputOutput: txInput}, + {txid: "same-height-input", height: 5, inputOutput: txInput}, + }}, + }, + }, + } + + txids := mergeXpubTxids(data) + got := make([]string, len(txids)) + for i := range txids { + got[i] = txids[i].txid + } + want := []string{"newest", "same-height-input", "duplicate"} + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("mergeXpubTxids order = %v, want %v", got, want) + } + if txids[2].inputOutput != txOutput { + t.Fatal("mergeXpubTxids did not preserve the first duplicate occurrence") + } +} + +func TestIsUnfilteredXpubTxidFilter(t *testing.T) { + if !isUnfilteredXpubTxidFilter(&AddressFilter{Vout: AddressFilterVoutOff}) { + t.Fatal("default xpub txid filter should be unfiltered") + } + if isUnfilteredXpubTxidFilter(&AddressFilter{Vout: AddressFilterVoutInputs}) { + t.Fatal("input filter should not be treated as unfiltered") + } + if isUnfilteredXpubTxidFilter(&AddressFilter{Vout: AddressFilterVoutOff, FromHeight: 1}) { + t.Fatal("height filter should not be treated as unfiltered") + } +} diff --git a/bchain/basechain.go b/bchain/basechain.go index d7fc310956..60a52370e4 100644 --- a/bchain/basechain.go +++ b/bchain/basechain.go @@ -1,6 +1,7 @@ package bchain import ( + "encoding/json" "errors" "math/big" ) @@ -39,43 +40,90 @@ func (b *BaseChain) GetMempoolEntry(txid string) (*MempoolEntry, error) { return nil, errors.New("GetMempoolEntry: not supported") } +// GetSPVProof is not supported by default. +// +// SYSCOIN +func (b *BaseChain) GetSPVProof(hash string) (json.RawMessage, error) { + return nil, errors.New("GetSPVProof: 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") +// EthereumTypeGetNonces is not supported +func (b *BaseChain) EthereumTypeGetNonces(addrDesc AddressDescriptor, withConfirmed bool) (uint64, uint64, bool, error) { + return 0, 0, false, 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") } -// EthereumTypeGetErc20ContractInfo is not supported -func (b *BaseChain) EthereumTypeGetErc20ContractInfo(contractDesc AddressDescriptor) (*Erc20Contract, 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") } // 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") +} + +// EthereumTypeGetErc20ContractBalances is not supported +func (b *BaseChain) EthereumTypeGetErc20ContractBalances(addrDesc AddressDescriptor, contractDescs []AddressDescriptor) ([]*big.Int, error) { + return nil, errors.New("not supported") } -func (b *BaseChain) GetChainTips() (string, error) { - return "", 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") } -func (b *BaseChain) GetSPVProof(hash string) (string, error) { - return "", errors.New("Not supported") + +func (b *BaseChain) EthereumTypeGetSupportedStakingPools() []string { + return nil } -func (p *BaseChain) FetchNEVMAssetDetails(assetGuid uint64) (*Asset, error) { - return nil, errors.New("Not supported") +func (b *BaseChain) EthereumTypeGetStakingPoolsData(addrDesc AddressDescriptor) ([]StakingPoolData, error) { + return nil, errors.New("not supported") } -func (p *BaseChain) GetContractExplorerBaseURL() string { - return "" +// 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 9741c36481..7feb62c9b4 100644 --- a/bchain/basemempool.go +++ b/bchain/basemempool.go @@ -7,19 +7,21 @@ import ( ) type addrIndex struct { - addrDesc string - n int32 - AssetInfo *AssetInfo + addrDesc string + n int32 + AssetInfo *AssetInfo // SYSCOIN } 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 @@ -28,7 +30,6 @@ type BaseMempool struct { mux sync.Mutex txEntries map[string]txEntry addrDescToTx map[string][]Outpoint - OnNewTxAddr OnNewTxAddrFunc OnNewTx OnNewTxFunc } @@ -71,19 +72,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) } } } @@ -105,28 +114,34 @@ func (m *BaseMempool) GetAllEntries() MempoolTxidEntries { sort.Sort(entries) return entries } + +// GetTxAssets returns all mempool entries touching a Syscoin SPT asset. +// +// SYSCOIN func (m *BaseMempool) GetTxAssets(assetGuid uint64) MempoolTxidEntries { m.mux.Lock() + defer m.mux.Unlock() mapTxid := make(map[string]struct{}) entries := make(MempoolTxidEntries, 0) for txid, entry := range m.txEntries { - if _, found := mapTxid[txid]; !found { - for _, addrIndex := range entry.addrIndexes { - if addrIndex.AssetInfo != nil && addrIndex.AssetInfo.AssetGuid == assetGuid { - mapTxid[txid] = struct{}{} - entries = append(entries, MempoolTxidEntry{ - Txid: txid, - Time: entry.time, - }) - break - } + if _, found := mapTxid[txid]; found { + continue + } + for _, addrIndex := range entry.addrIndexes { + if addrIndex.AssetInfo != nil && addrIndex.AssetInfo.AssetGuid == assetGuid { + mapTxid[txid] = struct{}{} + entries = append(entries, MempoolTxidEntry{ + Txid: txid, + Time: entry.time, + }) + break } } } - m.mux.Unlock() sort.Sort(entries) return entries } + // GetTransactionTime returns first seen time of a transaction func (m *BaseMempool) GetTransactionTime(txid string) uint32 { m.mux.Lock() @@ -144,13 +159,11 @@ func (m *BaseMempool) txToMempoolTx(tx *Tx) *MempoolTx { Blocktime: time.Now().Unix(), LockTime: tx.LockTime, Txid: tx.Txid, + VSize: tx.VSize, Version: tx.Version, Vout: tx.Vout, CoinSpecificData: tx.CoinSpecificData, } - if len(tx.Memo) > 0 { - mtx.Memo = tx.Memo - } mtx.Vin = make([]MempoolVin, len(tx.Vin)) for i, vin := range tx.Vin { mtx.Vin[i] = MempoolVin{ diff --git a/bchain/basemempool_test.go b/bchain/basemempool_test.go new file mode 100644 index 0000000000..9a78217ed0 --- /dev/null +++ b/bchain/basemempool_test.go @@ -0,0 +1,212 @@ +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) + } + }) + } +} + +func TestBaseMempool_GetTxAssets(t *testing.T) { + m := &BaseMempool{ + txEntries: map[string]txEntry{ + "tx-a": { + addrIndexes: []addrIndex{ + {addrDesc: "ad1", n: 0, AssetInfo: &AssetInfo{AssetGuid: 10}}, + {addrDesc: "ad2", n: 1, AssetInfo: &AssetInfo{AssetGuid: 10}}, + }, + time: 10, + }, + "tx-b": { + addrIndexes: []addrIndex{ + {addrDesc: "ad3", n: 0, AssetInfo: &AssetInfo{AssetGuid: 20}}, + }, + time: 30, + }, + "tx-c": { + addrIndexes: []addrIndex{ + {addrDesc: "ad4", n: 0}, + {addrDesc: "ad5", n: 1, AssetInfo: &AssetInfo{AssetGuid: 10}}, + }, + time: 20, + }, + }, + } + + got := m.GetTxAssets(10) + want := MempoolTxidEntries{ + {Txid: "tx-c", Time: 20}, + {Txid: "tx-a", Time: 10}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("GetTxAssets() got = %+v, want %+v", got, want) + } +} diff --git a/bchain/baseparser.go b/bchain/baseparser.go index ecd72d121b..9cbd251369 100644 --- a/bchain/baseparser.go +++ b/bchain/baseparser.go @@ -1,22 +1,25 @@ package bchain import ( + "encoding/binary" "encoding/hex" "encoding/json" - "encoding/binary" "math/big" + "strconv" "strings" - "github.com/gogo/protobuf/proto" + + vlq "github.com/bsm/go-vlq" "github.com/golang/glog" "github.com/juju/errors" - vlq "github.com/bsm/go-vlq" - "github.com/syscoin/blockbook/common" + "github.com/trezor/blockbook/common" + "google.golang.org/protobuf/proto" ) // BaseParser implements data parsing/handling functionality base for all other parsers type BaseParser struct { BlockAddressesToKeep int AmountDecimalPoint int + AddressAliases bool } // ParseBlock parses raw block to our Block struct - currently not implemented @@ -39,26 +42,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 +71,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 { @@ -104,6 +196,11 @@ func (p *BaseParser) AmountDecimals() int { return p.AmountDecimalPoint } +// UseAddressAliases returns true if address aliases are enabled +func (p *BaseParser) UseAddressAliases() bool { + return p.AddressAliases +} + // ParseTxFromJson parses JSON message containing transaction and returns Tx struct func (p *BaseParser) ParseTxFromJson(msg json.RawMessage) (*Tx, error) { var tx Tx @@ -130,10 +227,6 @@ func (p *BaseParser) PackedTxidLen() int { return 32 } -func (p *BaseParser) PackedTxIndexLen() int { - return p.PackedTxidLen() -} - // KeepBlockAddresses returns number of blocks which are to be kept in blockaddresses column func (p *BaseParser) KeepBlockAddresses() int { return p.BlockAddressesToKeep @@ -172,6 +265,11 @@ func (p *BaseParser) MinimumCoinbaseConfirmations() int { return 0 } +// SupportsVSize returns true if vsize of a transaction should be computed and returned by API +func (p *BaseParser) SupportsVSize() bool { + return false +} + // PackTx packs transaction to byte array using protobuf func (p *BaseParser) PackTx(tx *Tx, height uint32, blockTime int64) ([]byte, error) { var err error @@ -215,6 +313,7 @@ func (p *BaseParser) PackTx(tx *Tx, height uint32, blockTime int64) ([]byte, err Vin: pti, Vout: pto, Version: tx.Version, + VSize: tx.VSize, } if pt.Hex, err = hex.DecodeString(tx.Hex); err != nil { return nil, errors.Annotatef(err, "Hex %v", tx.Hex) @@ -275,6 +374,7 @@ func (p *BaseParser) UnpackTx(buf []byte) (*Tx, uint32, error) { Vin: vin, Vout: vout, Version: pt.Version, + VSize: pt.VSize, } return &tx, pt.Height, nil } @@ -305,313 +405,135 @@ func (p *BaseParser) DeriveAddressDescriptorsFromTo(descriptor *XpubDescriptor, return nil, errors.New("Not supported") } -// EthereumTypeGetErc20FromTx is unsupported -func (p *BaseParser) EthereumTypeGetErc20FromTx(tx *Tx) ([]Erc20Transfer, error) { +// EthereumTypeGetTokenTransfersFromTx is unsupported +func (p *BaseParser) EthereumTypeGetTokenTransfersFromTx(tx *Tx) (TokenTransfers, error) { return nil, errors.New("Not supported") } -func (p *BaseParser) IsSyscoinTx(nVersion int32, nHeight uint32) bool { - return false -} -func (p *BaseParser) IsSyscoinMintTx(nVersion int32) bool { - return false +// GetEthereumTxData returns default pending status for non-Ethereum-like chains. +func (p *BaseParser) GetEthereumTxData(tx *Tx) *EthereumTxData { + return &EthereumTxData{Status: TxStatusPending} } -func (p *BaseParser) IsAssetAllocationTx(nVersion int32) bool { - return false -} -func (p *BaseParser) GetAssetsMaskFromVersion(nVersion int32) AssetsMask { - return BaseCoinMask -} -func (p *BaseParser) GetAssetTypeFromVersion(nVersion int32) *TokenType { - return nil +// GetChainExtraData returns optional normalized chain-specific transaction data. +func (p *BaseParser) GetChainExtraData(tx *Tx) (json.RawMessage, error) { + return nil, nil } -func (p *BaseParser) TryGetOPReturn(script []byte) []byte { - return nil + +// GetChainExtraPayloadType identifies the shape of normalized chain-specific transaction data. +func (p *BaseParser) GetChainExtraPayloadType() ChainExtraPayloadType { + return ChainExtraPayloadTypeUnknown } -func (p *BaseParser) GetMaxAddrLength() int { - return 1024 + +// FormatAddressAlias makes possible to do coin specific formatting to an address alias +func (p *BaseParser) FormatAddressAlias(address string, name string) string { + return name } -func (p *BaseParser) PackAddrBalance(ab *AddrBalance, buf, varBuf []byte) []byte { + +func (b *BaseParser) ParseInputData(signatures *[]FourByteSignature, data string) *EthereumParsedInputData { return nil } -func (p *BaseParser) UnpackAddrBalance(buf []byte, txidUnpackedLen int, detail AddressBalanceDetail) (*AddrBalance, error) { - return nil, errors.New("Not supported") + +// SYSCOIN: default no-op hooks for Syscoin SPT parsing/indexing. Non-Syscoin +// parsers keep upstream behavior and never enter these code paths. +func (p *BaseParser) IsSyscoinTx(nVersion int32, nHeight uint32) bool { return false } +func (p *BaseParser) IsSyscoinMintTx(nVersion int32) bool { return false } +func (p *BaseParser) IsAssetAllocationTx(nVersion int32) bool { return false } +func (p *BaseParser) GetAssetsMaskFromVersion(nVersion int32) AssetsMask { + return BaseCoinMask } +func (p *BaseParser) GetAssetTypeFromVersion(nVersion int32) *TokenType { return nil } +func (p *BaseParser) TryGetOPReturn(script []byte) []byte { return nil } +func (p *BaseParser) GetMaxAddrLength() int { return 1024 } func (p *BaseParser) PackAssetKey(assetGuid uint64, height uint32) []byte { return nil } -func (p *BaseParser) UnpackAssetKey(buf []byte) (uint64, uint32) { - return 0, 0 -} -func (p *BaseParser) PackAssetTxIndex(txAsset *TxAsset) []byte { - return nil -} +func (p *BaseParser) UnpackAssetKey(buf []byte) (uint64, uint32) { return 0, 0 } +func (p *BaseParser) PackAssetTxIndex(txAsset *TxAsset) []byte { return nil } func (p *BaseParser) UnpackAssetTxIndex(buf []byte) []*TxAssetIndex { return nil } - func (p *BaseParser) GetAssetAllocationFromData(sptData []byte, txVersion int32) (*AssetAllocation, []byte, error) { return nil, nil, errors.New("Not supported") } - func (p *BaseParser) GetAssetAllocationFromDesc(addrDesc *AddressDescriptor, txVersion int32) (*AssetAllocation, []byte, error) { return nil, nil, errors.New("Not supported") } func (p *BaseParser) GetAllocationFromTx(tx *Tx) (*AssetAllocation, []byte, error) { return nil, nil, errors.New("Not supported") } -func (p *BaseParser) LoadAssets(tx *Tx) error { - return errors.New("Not supported") -} +func (p *BaseParser) LoadAssets(tx *Tx) error { return errors.New("Not supported") } func (p *BaseParser) WitnessPubKeyHashFromKeyID(keyId []byte) (string, error) { return "", errors.New("Not supported") } -func (p *BaseParser) AppendAssetInfo(assetInfo *AssetInfo, buf []byte, varBuf []byte) []byte { +func (p *BaseParser) AppendAssetInfo(assetInfo *AssetInfo, buf []byte, varBuf []byte) []byte { return nil } -func (p *BaseParser) UnpackAssetInfo(assetInfo *AssetInfo, buf []byte) int { - return 0 -} -const PackedHeightBytes = 4 -func (p *BaseParser) PackAddressKey(addrDesc AddressDescriptor, height uint32) []byte { - buf := make([]byte, len(addrDesc)+PackedHeightBytes) - copy(buf, addrDesc) - // pack height as binary complement to achieve ordering from newest to oldest block - binary.BigEndian.PutUint32(buf[len(addrDesc):], ^height) - return buf -} - -func (p *BaseParser) UnpackAddressKey(key []byte) ([]byte, uint32, error) { - i := len(key) - PackedHeightBytes - if i <= 0 { - return nil, 0, errors.New("Invalid address key") - } - // height is packed in binary complement, convert it - return key[:i], ^p.UnpackUint(key[i : i+PackedHeightBytes]), nil -} +func (p *BaseParser) UnpackAssetInfo(assetInfo *AssetInfo, buf []byte) int { return 0 } +// SYSCOIN: legacy packing helpers used by the Syscoin SPT serializers. func (p *BaseParser) PackUint(i uint32) []byte { buf := make([]byte, 4) binary.BigEndian.PutUint32(buf, i) return buf } -func (p *BaseParser) PackUint64(i uint64) []byte { - buf := make([]byte, 8) - binary.BigEndian.PutUint64(buf, i) - return buf -} - func (p *BaseParser) UnpackUint(buf []byte) uint32 { return binary.BigEndian.Uint32(buf) } -func (p *BaseParser) UnpackUint64(buf []byte) uint64 { - return binary.BigEndian.Uint64(buf) -} - func (p *BaseParser) PackVarint32(i int32, buf []byte) int { return vlq.PutInt(buf, int64(i)) } -func (p *BaseParser) PackVarint(i int, buf []byte) int { - return vlq.PutInt(buf, int64(i)) -} - func (p *BaseParser) PackVaruint(i uint, buf []byte) int { return vlq.PutUint(buf, uint64(i)) } + func (p *BaseParser) PackVaruint64(i uint64, buf []byte) int { return vlq.PutUint(buf, i) } -func (p *BaseParser) UnpackVarint32(buf []byte) (int32, int) { - i, ofs := vlq.Int(buf) - return int32(i), ofs -} - -func (p *BaseParser) UnpackVarint(buf []byte) (int, int) { - i, ofs := vlq.Int(buf) - return int(i), ofs -} func (p *BaseParser) UnpackVaruint(buf []byte) (uint, int) { - i, ofs := vlq.Uint(buf) - return uint(i), ofs + i, l := vlq.Uint(buf) + return uint(i), l } func (p *BaseParser) UnpackVaruint64(buf []byte) (uint64, int) { - i, ofs := vlq.Uint(buf) - return i, ofs -} - -func (p *BaseParser) UnpackVarBytes(buf []byte) ([]byte, int) { - txvalue, l := p.UnpackVaruint(buf) - bufValue := append([]byte(nil), buf[l:l+int(txvalue)]...) - return bufValue, (l+int(txvalue)) -} - -func (p *BaseParser) PackVarBytes(bufValue []byte) []byte { - len := uint(len(bufValue)) - var buf []byte - varBuf := make([]byte, vlq.MaxLen64) - l := p.PackVaruint(len, varBuf) - buf = append(buf, varBuf[:l]...) - if len > 0 { - buf = append(buf, bufValue...) - } - return buf -} - -const ( - // number of bits in a big.Word - wordBits = 32 << (uint64(^big.Word(0)) >> 63) - // number of bytes in a big.Word - wordBytes = wordBits / 8 - // max packed bigint words - maxPackedBigintWords = (256 - wordBytes) / wordBytes - maxPackedBigintBytes = 249 -) - -func (p *BaseParser) MaxPackedBigintBytes() int { - return maxPackedBigintBytes + return vlq.Uint(buf) } -// big int is packed in BigEndian order without memory allocation as 1 byte length followed by bytes of big int -// number of written bytes is returned -// limitation: bigints longer than 248 bytes are truncated to 248 bytes -// caution: buffer must be big enough to hold the packed big int, buffer 249 bytes big is always safe func (p *BaseParser) PackBigint(bi *big.Int, buf []byte) int { - w := bi.Bits() - lw := len(w) - // zero returns only one byte - zero length - if lw == 0 { - buf[0] = 0 - return 1 - } - // pack the most significant word in a special way - skip leading zeros - w0 := w[lw-1] - fb := 8 - mask := big.Word(0xff) << (wordBits - 8) - for w0&mask == 0 { - fb-- - mask >>= 8 - } - for i := fb; i > 0; i-- { - buf[i] = byte(w0) - w0 >>= 8 - } - // if the big int is too big (> 2^1984), the number of bytes would not fit to 1 byte - // in this case, truncate the number, it is not expected to work with this big numbers as amounts - s := 0 - if lw > maxPackedBigintWords { - s = lw - maxPackedBigintWords - } - // pack the rest of the words in reverse order - for j := lw - 2; j >= s; j-- { - d := w[j] - for i := fb + wordBytes; i > fb; i-- { - buf[i] = byte(d) - d >>= 8 - } - fb += wordBytes + if bi == nil { + return p.PackVaruint(0, buf) } - buf[0] = byte(fb) - return fb + 1 -} - -func (p *BaseParser) UnpackBigint(buf []byte) (big.Int, int) { - var r big.Int - l := int(buf[0]) + 1 - r.SetBytes(buf[1:l]) - return r, l -} - -func (p *BaseParser) PackTxIndexes(txi []TxIndexes) []byte { - buf := make([]byte, 0, 32) - bvout := make([]byte, vlq.MaxLen32) - // store the txs in reverse order for ordering from newest to oldest - for j := len(txi) - 1; j >= 0; j-- { - t := &txi[j] - buf = append(buf, []byte(t.BtxID)...) - for i, index := range t.Indexes { - index <<= 1 - if i == len(t.Indexes)-1 { - index |= 1 - } - l := p.PackVarint32(index, bvout) - buf = append(buf, bvout[:l]...) - } + if bi.Sign() < 0 { + return 0 } - return buf + b := bi.Bytes() + l := p.PackVaruint(uint(len(b)), buf) + copy(buf[l:], b) + return l + len(b) } -func (p *BaseParser) UnpackTxIndexes(txindexes *[]int32, buf *[]byte) error { - for { - index, l := p.UnpackVarint32(*buf) - *txindexes = append(*txindexes, index>>1) - *buf = (*buf)[l:] - if index&1 == 1 { - return nil - } else if len(*buf) == 0 { - return errors.New("rocksdb: index buffer length is zero") - } +func (p *BaseParser) UnpackBigint(buf []byte) (big.Int, int) { + var bi big.Int + l, ll := p.UnpackVaruint(buf) + if l == 0 { + return bi, ll } - return nil -} - -func (p *BaseParser) UnpackTxIndexAssets(assetGuids *[]uint64, buf *[]byte) uint { - return uint(0) -} - -func (p *BaseParser) PackTxAddresses(ta *TxAddresses, buf []byte, varBuf []byte) []byte { - return nil -} - -func (p *BaseParser) AppendTxInput(txi *TxInput, buf []byte, varBuf []byte) []byte { - return nil -} - -func (p *BaseParser) AppendTxOutput(txo *TxOutput, buf []byte, varBuf []byte) []byte { - return nil -} - -func (p *BaseParser) UnpackTxAddresses(buf []byte) (*TxAddresses, error) { - return nil, errors.New("Not supported") -} - -func (p *BaseParser) UnpackTxInput(ti *TxInput, buf []byte) int { - return 0 -} - -func (p *BaseParser) UnpackTxOutput(to *TxOutput, buf []byte) int { - return 0 -} - -func (p *BaseParser) PackOutpoints(outpoints []DbOutpoint) []byte { - return nil -} - -func (p *BaseParser) UnpackNOutpoints(buf []byte) ([]DbOutpoint, int, error) { - return nil, 0, errors.New("Not supported") -} - -func (p *BaseParser) PackBlockInfo(block *DbBlockInfo) ([]byte, error) { - return nil, errors.New("Not supported") + bi.SetBytes(buf[ll : ll+int(l)]) + return bi, ll + int(l) } -func (p *BaseParser) UnpackBlockInfo(buf []byte) (*DbBlockInfo, error) { - return nil, errors.New("Not supported") -} - -func (p *BaseParser) UnpackAsset(buf []byte) (*Asset, error) { - return nil, nil +func (p *BaseParser) PackVarBytes(bufValue []byte) []byte { + buf := make([]byte, vlq.MaxLen64+len(bufValue)) + l := p.PackVaruint(uint(len(bufValue)), buf) + buf = append(buf[:l], bufValue...) + return buf } -func (p *BaseParser) PackAsset(asset *Asset) ([]byte, error) { - return nil, nil -} -func (p *BaseParser) UnpackTxIndexType(buf []byte) (AssetsMask, int) { - return AllMask, 0 +func (p *BaseParser) UnpackVarBytes(buf []byte) ([]byte, int) { + l, ll := p.UnpackVaruint(buf) + return append([]byte(nil), buf[ll:ll+int(l)]...), ll + int(l) } - diff --git a/bchain/baseparser_test.go b/bchain/baseparser_test.go index d395531b5b..8ba773cdfa 100644 --- a/bchain/baseparser_test.go +++ b/bchain/baseparser_test.go @@ -6,7 +6,7 @@ import ( "math/big" "testing" - "github.com/syscoin/blockbook/common" + "github.com/trezor/blockbook/common" ) func NewBaseParser(adp int) *BaseParser { @@ -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 new file mode 100644 index 0000000000..028d18246e --- /dev/null +++ b/bchain/coins/avalanche/avalancherpc.go @@ -0,0 +1,165 @@ +package avalanche + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + + jsontypes "github.com/ava-labs/avalanchego/utils/json" + "github.com/ethereum/go-ethereum/common" + "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" + "github.com/trezor/blockbook/bchain/coins/eth" +) + +const ( + // MainNet is production network + 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 *rpc.Client +} + +// NewAvalancheRPC returns new AvalancheRPC instance. +func NewAvalancheRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { + c, err := eth.NewEthereumRPC(config, pushHandler) + if err != nil { + return nil, err + } + + s := &AvalancheRPC{ + EthereumRPC: c.(*eth.EthereumRPC), + } + + return s, nil +} + +// Initialize avalanche rpc interface +func (b *AvalancheRPC) Initialize() error { + 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) + if err != nil { + return err + } + + scheme := "http" + if rpcUrl.Scheme == "wss" || rpcUrl.Scheme == "https" { + scheme = "https" + } + + infoClient, err := rpc.DialHTTP(fmt.Sprintf("%s://%s/ext/info", scheme, rpcUrl.Host)) + if err != nil { + return err + } + + // set chain specific + b.Client = client + b.RPC = rpcClient + b.info = infoClient + b.MainNetChainID = MainNet + b.NewBlock = &AvalancheNewBlock{channel: make(chan *Header)} + b.NewTx = &AvalancheNewTx{channel: make(chan common.Hash)} + + 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 +} + +// GetChainInfo returns information about the connected backend +func (b *AvalancheRPC) GetChainInfo() (*bchain.ChainInfo, error) { + ci, err := b.EthereumRPC.GetChainInfo() + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) + defer cancel() + + 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 err := b.info.CallContext(ctx, &v, "info.getNodeVersion"); err == nil { + if avm, ok := v.VMVersions["avm"]; ok { + ci.Version = avm + } + } + + return ci, nil +} diff --git a/bchain/coins/avalanche/evm.go b/bchain/coins/avalanche/evm.go new file mode 100644 index 0000000000..7593593ce1 --- /dev/null +++ b/bchain/coins/avalanche/evm.go @@ -0,0 +1,177 @@ +package avalanche + +import ( + "context" + "math/big" + "strings" + + "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 + *AvalancheRPCClient +} + +// HeaderByNumber returns a block header that implements the EVMHeader interface +func (c *AvalancheClient) HeaderByNumber(ctx context.Context, number *big.Int) (bchain.EVMHeader, error) { + 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: 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.(ethereum.CallMsg)) +} + +// BalanceAt returns the balance for the given account at a specific block, or latest known block if no block number is provided +func (c *AvalancheClient) BalanceAt(ctx context.Context, addrDesc bchain.AddressDescriptor, blockNumber *big.Int) (*big.Int, error) { + return c.Client.BalanceAt(ctx, common.BytesToAddress(addrDesc), blockNumber) +} + +// NonceAt returns the nonce for the given account at a specific block, or latest known block if no block number is provided +func (c *AvalancheClient) NonceAt(ctx context.Context, addrDesc bchain.AddressDescriptor, blockNumber *big.Int) (uint64, error) { + return c.Client.NonceAt(ctx, common.BytesToAddress(addrDesc), blockNumber) +} + +// AvalancheRPCClient wraps an rpc client to implement the EVMRPCClient interface +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...) + if err != nil { + return nil, err + } + + return &AvalancheClientSubscription{ClientSubscription: sub}, nil +} + +// CallContext performs a JSON-RPC call with the given arguments +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 + // 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 { + if strings.Contains(err.Error(), "cannot query unfinalized data") { + return bchain.ErrBlockNotFound + } + return err + } + return nil +} + +// AvalancheHeader wraps a block header to implement the EVMHeader interface +type AvalancheHeader struct { + *Header +} + +// Hash returns the block hash as a hex string +func (h *AvalancheHeader) Hash() string { + return h.Header.Hash().Hex() +} + +// Number returns the block number +func (h *AvalancheHeader) Number() *big.Int { + return h.Header.Number +} + +// Difficulty returns the block difficulty +func (h *AvalancheHeader) Difficulty() *big.Int { + return h.Header.Difficulty +} + +// AvalancheHash wraps a transaction hash to implement the EVMHash interface +type AvalancheHash struct { + common.Hash +} + +// AvalancheClientSubscription wraps a client subcription to implement the EVMClientSubscription interface +type AvalancheClientSubscription struct { + *rpc.ClientSubscription +} + +// AvalancheNewBlock wraps a block header channel to implement the EVMNewBlockSubscriber interface +type AvalancheNewBlock struct { + channel chan *Header +} + +// Channel returns the underlying channel as an empty interface +func (s *AvalancheNewBlock) Channel() interface{} { + return s.channel +} + +// Read from the underlying channel and return a block header that implements the EVMHeader interface +func (s *AvalancheNewBlock) Read() (bchain.EVMHeader, bool) { + h, ok := <-s.channel + return &AvalancheHeader{Header: h}, ok +} + +// Close the underlying channel +func (s *AvalancheNewBlock) Close() { + close(s.channel) +} + +// AvalancheNewTx wraps a transaction hash channel to conform with the EVMNewTxSubscriber interface +type AvalancheNewTx struct { + channel chan common.Hash +} + +// Channel returns the underlying channel as an empty interface +func (s *AvalancheNewTx) Channel() interface{} { + return s.channel +} + +// Read from the underlying channel and return a transaction hash that implements the EVMHash interface +func (s *AvalancheNewTx) Read() (bchain.EVMHash, bool) { + h, ok := <-s.channel + return &AvalancheHash{Hash: h}, ok +} + +// Close the underlying channel +func (s *AvalancheNewTx) Close() { + close(s.channel) +} 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.go b/bchain/coins/bch/bcashparser.go index 9b60ec3dfd..f63f27fc05 100644 --- a/bchain/coins/bch/bcashparser.go +++ b/bchain/coins/bch/bcashparser.go @@ -8,8 +8,8 @@ import ( "github.com/martinboehm/btcutil/chaincfg" "github.com/martinboehm/btcutil/txscript" "github.com/schancel/cashaddr-converter/address" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // AddressFormat type is used to specify different formats of address diff --git a/bchain/coins/bch/bcashparser_test.go b/bchain/coins/bch/bcashparser_test.go index 25eb2721e1..3aec739606 100644 --- a/bchain/coins/bch/bcashparser_test.go +++ b/bchain/coins/bch/bcashparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -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/bch/bcashrpc.go b/bchain/coins/bch/bcashrpc.go index 2c3a731cf3..2a36a760d1 100644 --- a/bchain/coins/bch/bcashrpc.go +++ b/bchain/coins/bch/bcashrpc.go @@ -8,8 +8,8 @@ import ( "github.com/golang/glog" "github.com/juju/errors" "github.com/martinboehm/bchutil" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // BCashRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/bellcoin/bellcoinparser.go b/bchain/coins/bellcoin/bellcoinparser.go index bf7ca7bf18..c4d5b1c659 100644 --- a/bchain/coins/bellcoin/bellcoinparser.go +++ b/bchain/coins/bellcoin/bellcoinparser.go @@ -3,7 +3,7 @@ package bellcoin import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/btc" ) // magic numbers diff --git a/bchain/coins/bellcoin/bellcoinparser_test.go b/bchain/coins/bellcoin/bellcoinparser_test.go index dd5d9e65a8..e747fe0312 100644 --- a/bchain/coins/bellcoin/bellcoinparser_test.go +++ b/bchain/coins/bellcoin/bellcoinparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -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/bellcoin/bellcoinrpc.go b/bchain/coins/bellcoin/bellcoinrpc.go index 69631f9cc2..7d16868304 100644 --- a/bchain/coins/bellcoin/bellcoinrpc.go +++ b/bchain/coins/bellcoin/bellcoinrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // BellcoinRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/bitcore/bitcoreparser.go b/bchain/coins/bitcore/bitcoreparser.go index a248bbaa09..2cdaa9067d 100644 --- a/bchain/coins/bitcore/bitcoreparser.go +++ b/bchain/coins/bitcore/bitcoreparser.go @@ -3,8 +3,8 @@ package bitcore import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) const ( diff --git a/bchain/coins/bitcore/bitcoreparser_test.go b/bchain/coins/bitcore/bitcoreparser_test.go index ecc0ee917d..d3330de6c1 100644 --- a/bchain/coins/bitcore/bitcoreparser_test.go +++ b/bchain/coins/bitcore/bitcoreparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -81,7 +81,7 @@ func Test_GetAddrDescFromAddress_Mainnet(t *testing.T) { var ( testTx1 bchain.Tx - testTxPacked1 = "0a20fcd4f2e45787a33571bc9b2ce939d6e8e51fa053296de9240f05455702bd954012e2010200000001f69bd1fd76e52a426f21332e3b7cfbc3350eacbd21c6e0c11a7ae11919803ef0010000006b483045022100d1fa62b9d7860a03e1dcd4734fe42457cb508ebb49e896d7a77748d997d09fba022005f1657b39451afe97076d8667fe5f6f18ca76391521ab84d09d5b82137d933b0121035aaf032f13761f27465467dc73f1998a80dd4d85a6353d2832a7244d7b591d3effffffff02a87322b3010000001976a914d0c320db3fbd0abe2b6fe31a3bca4fed8ce8669588ac94b94f37000000001976a9145584ee07090af59938e991c9d8e9e945c99a449f88ac0000000018858a8ce205200028f9f3133299010a001220f03e801919e17a1ac1e0c621bdac0e35c3fb7c3b2e33216f422ae576fdd19bf61801226b483045022100d1fa62b9d7860a03e1dcd4734fe42457cb508ebb49e896d7a77748d997d09fba022005f1657b39451afe97076d8667fe5f6f18ca76391521ab84d09d5b82137d933b0121035aaf032f13761f27465467dc73f1998a80dd4d85a6353d2832a7244d7b591d3e28ffffffff0f3a480a0501b32273a810001a1976a914d0c320db3fbd0abe2b6fe31a3bca4fed8ce8669588ac22223259336546797741414673617039757139726942474143684e326858356a6e7268753a470a04374fb99410011a1976a9145584ee07090af59938e991c9d8e9e945c99a449f88ac2222324c6f7a646b704450723562356b6a66445042315a76454c597735734475684139594002" + testTxPacked1 = "0a20fcd4f2e45787a33571bc9b2ce939d6e8e51fa053296de9240f05455702bd954012e2010200000001f69bd1fd76e52a426f21332e3b7cfbc3350eacbd21c6e0c11a7ae11919803ef0010000006b483045022100d1fa62b9d7860a03e1dcd4734fe42457cb508ebb49e896d7a77748d997d09fba022005f1657b39451afe97076d8667fe5f6f18ca76391521ab84d09d5b82137d933b0121035aaf032f13761f27465467dc73f1998a80dd4d85a6353d2832a7244d7b591d3effffffff02a87322b3010000001976a914d0c320db3fbd0abe2b6fe31a3bca4fed8ce8669588ac94b94f37000000001976a9145584ee07090af59938e991c9d8e9e945c99a449f88ac0000000018858a8ce20528f9f3133297011220f03e801919e17a1ac1e0c621bdac0e35c3fb7c3b2e33216f422ae576fdd19bf61801226b483045022100d1fa62b9d7860a03e1dcd4734fe42457cb508ebb49e896d7a77748d997d09fba022005f1657b39451afe97076d8667fe5f6f18ca76391521ab84d09d5b82137d933b0121035aaf032f13761f27465467dc73f1998a80dd4d85a6353d2832a7244d7b591d3e28ffffffff0f3a460a0501b32273a81a1976a914d0c320db3fbd0abe2b6fe31a3bca4fed8ce8669588ac22223259336546797741414673617039757139726942474143684e326858356a6e7268753a470a04374fb99410011a1976a9145584ee07090af59938e991c9d8e9e945c99a449f88ac2222324c6f7a646b704450723562356b6a66445042315a76454c597735734475684139594002" ) func init() { diff --git a/bchain/coins/bitcore/bitcorerpc.go b/bchain/coins/bitcore/bitcorerpc.go index a675f54b3d..97da4044d3 100644 --- a/bchain/coins/bitcore/bitcorerpc.go +++ b/bchain/coins/bitcore/bitcorerpc.go @@ -5,8 +5,8 @@ import ( "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // BitcoreRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/bitzeny/bitzenyparser.go b/bchain/coins/bitzeny/bitzenyparser.go index f5a9bdbc10..82ae2d0ef5 100644 --- a/bchain/coins/bitzeny/bitzenyparser.go +++ b/bchain/coins/bitzeny/bitzenyparser.go @@ -1,7 +1,7 @@ package bitzeny import ( - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/btc" "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" diff --git a/bchain/coins/bitzeny/bitzenyparser_test.go b/bchain/coins/bitzeny/bitzenyparser_test.go index 5d402e4e3e..112641c5df 100644 --- a/bchain/coins/bitzeny/bitzenyparser_test.go +++ b/bchain/coins/bitzeny/bitzenyparser_test.go @@ -9,8 +9,8 @@ import ( "reflect" "testing" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" "github.com/martinboehm/btcutil/chaincfg" ) diff --git a/bchain/coins/bitzeny/bitzenyrpc.go b/bchain/coins/bitzeny/bitzenyrpc.go index 12e02ec111..ec9fc152a3 100644 --- a/bchain/coins/bitzeny/bitzenyrpc.go +++ b/bchain/coins/bitzeny/bitzenyrpc.go @@ -3,8 +3,8 @@ package bitzeny import ( "encoding/json" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" "github.com/golang/glog" ) diff --git a/bchain/coins/blockchain.go b/bchain/coins/blockchain.go index 4a3f4704bd..7f3b674247 100644 --- a/bchain/coins/blockchain.go +++ b/bchain/coins/blockchain.go @@ -4,52 +4,64 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" "math/big" + "os" "reflect" "time" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/bch" - "github.com/syscoin/blockbook/bchain/coins/bellcoin" - "github.com/syscoin/blockbook/bchain/coins/bitcore" - "github.com/syscoin/blockbook/bchain/coins/bitzeny" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/btg" - "github.com/syscoin/blockbook/bchain/coins/cpuchain" - "github.com/syscoin/blockbook/bchain/coins/dash" - "github.com/syscoin/blockbook/bchain/coins/dcr" - "github.com/syscoin/blockbook/bchain/coins/deeponion" - "github.com/syscoin/blockbook/bchain/coins/digibyte" - "github.com/syscoin/blockbook/bchain/coins/divi" - "github.com/syscoin/blockbook/bchain/coins/dogecoin" - "github.com/syscoin/blockbook/bchain/coins/eth" - "github.com/syscoin/blockbook/bchain/coins/flo" - "github.com/syscoin/blockbook/bchain/coins/fujicoin" - "github.com/syscoin/blockbook/bchain/coins/gamecredits" - "github.com/syscoin/blockbook/bchain/coins/grs" - "github.com/syscoin/blockbook/bchain/coins/koto" - "github.com/syscoin/blockbook/bchain/coins/liquid" - "github.com/syscoin/blockbook/bchain/coins/litecoin" - "github.com/syscoin/blockbook/bchain/coins/monacoin" - "github.com/syscoin/blockbook/bchain/coins/monetaryunit" - "github.com/syscoin/blockbook/bchain/coins/myriad" - "github.com/syscoin/blockbook/bchain/coins/namecoin" - "github.com/syscoin/blockbook/bchain/coins/nuls" - "github.com/syscoin/blockbook/bchain/coins/omotenashicoin" - "github.com/syscoin/blockbook/bchain/coins/pivx" - "github.com/syscoin/blockbook/bchain/coins/polis" - "github.com/syscoin/blockbook/bchain/coins/qtum" - "github.com/syscoin/blockbook/bchain/coins/ravencoin" - "github.com/syscoin/blockbook/bchain/coins/ritocoin" - "github.com/syscoin/blockbook/bchain/coins/sys" - "github.com/syscoin/blockbook/bchain/coins/unobtanium" - "github.com/syscoin/blockbook/bchain/coins/vertcoin" - "github.com/syscoin/blockbook/bchain/coins/viacoin" - "github.com/syscoin/blockbook/bchain/coins/vipstarcoin" - "github.com/syscoin/blockbook/bchain/coins/zec" - "github.com/syscoin/blockbook/common" + "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" + "github.com/trezor/blockbook/bchain/coins/dash" + "github.com/trezor/blockbook/bchain/coins/dcr" + "github.com/trezor/blockbook/bchain/coins/deeponion" + "github.com/trezor/blockbook/bchain/coins/digibyte" + "github.com/trezor/blockbook/bchain/coins/divi" + "github.com/trezor/blockbook/bchain/coins/dogecoin" + "github.com/trezor/blockbook/bchain/coins/ecash" + "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/bchain/coins/firo" + "github.com/trezor/blockbook/bchain/coins/flo" + "github.com/trezor/blockbook/bchain/coins/fujicoin" + "github.com/trezor/blockbook/bchain/coins/gamecredits" + "github.com/trezor/blockbook/bchain/coins/grs" + "github.com/trezor/blockbook/bchain/coins/koto" + "github.com/trezor/blockbook/bchain/coins/liquid" + "github.com/trezor/blockbook/bchain/coins/litecoin" + "github.com/trezor/blockbook/bchain/coins/monacoin" + "github.com/trezor/blockbook/bchain/coins/monetaryunit" + "github.com/trezor/blockbook/bchain/coins/myriad" + "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" + // SYSCOIN + sys "github.com/trezor/blockbook/bchain/coins/sys" + "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" + "github.com/trezor/blockbook/bchain/coins/vipstarcoin" + "github.com/trezor/blockbook/bchain/coins/zec" + "github.com/trezor/blockbook/common" ) type blockChainFactory func(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) @@ -60,14 +72,24 @@ 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 BlockChainFactories["Zcash Testnet"] = zec.NewZCashRPC BlockChainFactories["Ethereum"] = eth.NewEthereumRPC + BlockChainFactories["Ethereum Archive"] = eth.NewEthereumRPC BlockChainFactories["Ethereum Classic"] = eth.NewEthereumRPC - BlockChainFactories["Ethereum Testnet Ropsten"] = 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 + BlockChainFactories["Bgold Testnet"] = btg.NewBGoldRPC BlockChainFactories["Dash"] = dash.NewDashRPC BlockChainFactories["Dash Testnet"] = dash.NewDashRPC BlockChainFactories["Decred"] = dcr.NewDecredRPC @@ -78,6 +100,7 @@ func init() { BlockChainFactories["Litecoin"] = litecoin.NewLitecoinRPC BlockChainFactories["Litecoin Testnet"] = litecoin.NewLitecoinRPC BlockChainFactories["Dogecoin"] = dogecoin.NewDogecoinRPC + BlockChainFactories["Dogecoin Testnet"] = dogecoin.NewDogecoinRPC BlockChainFactories["Vertcoin"] = vertcoin.NewVertcoinRPC BlockChainFactories["Vertcoin Testnet"] = vertcoin.NewVertcoinRPC BlockChainFactories["Namecoin"] = namecoin.NewNamecoinRPC @@ -85,13 +108,17 @@ func init() { BlockChainFactories["Monacoin Testnet"] = monacoin.NewMonacoinRPC BlockChainFactories["MonetaryUnit"] = monetaryunit.NewMonetaryUnitRPC BlockChainFactories["DigiByte"] = digibyte.NewDigiByteRPC + BlockChainFactories["DigiByte Testnet"] = digibyte.NewDigiByteRPC BlockChainFactories["Myriad"] = myriad.NewMyriadRPC BlockChainFactories["Liquid"] = liquid.NewLiquidRPC BlockChainFactories["Groestlcoin"] = grs.NewGroestlcoinRPC BlockChainFactories["Groestlcoin Testnet"] = grs.NewGroestlcoinRPC + BlockChainFactories["Groestlcoin Signet"] = grs.NewGroestlcoinRPC + BlockChainFactories["Groestlcoin Regtest"] = grs.NewGroestlcoinRPC BlockChainFactories["PIVX"] = pivx.NewPivXRPC BlockChainFactories["PIVX Testnet"] = pivx.NewPivXRPC BlockChainFactories["Polis"] = polis.NewPolisRPC + BlockChainFactories["Firo"] = firo.NewFiroRPC BlockChainFactories["Fujicoin"] = fujicoin.NewFujicoinRPC BlockChainFactories["Flo"] = flo.NewFloRPC BlockChainFactories["Bellcoin"] = bellcoin.NewBellcoinRPC @@ -100,42 +127,48 @@ func init() { BlockChainFactories["Qtum Testnet"] = qtum.NewQtumRPC BlockChainFactories["NULS"] = nuls.NewNulsRPC BlockChainFactories["VIPSTARCOIN"] = vipstarcoin.NewVIPSTARCOINRPC - BlockChainFactories["ZelCash"] = zec.NewZCashRPC + BlockChainFactories["Flux"] = zec.NewZCashRPC BlockChainFactories["Ravencoin"] = ravencoin.NewRavencoinRPC BlockChainFactories["Ritocoin"] = ritocoin.NewRitocoinRPC BlockChainFactories["Divi"] = divi.NewDiviRPC BlockChainFactories["CPUchain"] = cpuchain.NewCPUchainRPC BlockChainFactories["Unobtanium"] = unobtanium.NewUnobtaniumRPC BlockChainFactories["DeepOnion"] = deeponion.NewDeepOnionRPC + BlockChainFactories["SnowGem"] = snowgem.NewSnowGemRPC BlockChainFactories["Bitcore"] = bitcore.NewBitcoreRPC - BlockChainFactories["Syscoin"] = syscoin.NewSyscoinRPC - BlockChainFactories["Syscoin Testnet"] = syscoin.NewSyscoinRPC BlockChainFactories["Omotenashicoin"] = omotenashicoin.NewOmotenashiCoinRPC BlockChainFactories["Omotenashicoin Testnet"] = omotenashicoin.NewOmotenashiCoinRPC BlockChainFactories["BitZeny"] = bitzeny.NewBitZenyRPC -} - -// 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["Trezarcoin"] = trezarcoin.NewTrezarcoinRPC + BlockChainFactories["ECash"] = ecash.NewECashRPC + BlockChainFactories["Avalanche"] = avalanche.NewAvalancheRPC + BlockChainFactories["Avalanche Archive"] = avalanche.NewAvalancheRPC + 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 + // SYSCOIN + BlockChainFactories["Syscoin"] = sys.NewSyscoinRPC + BlockChainFactories["Syscoin Testnet"] = sys.NewSyscoinRPC +} + +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) } @@ -152,6 +185,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 @@ -184,8 +220,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 { @@ -263,6 +299,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) @@ -278,19 +319,26 @@ 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) SendRawTransactionWithOpts(p bchain.SendRawTransactionParams) (string, error) { - o, ok := c.b.(bchain.SendRawTransactionOpts) - if !ok { - return "", fmt.Errorf("maxfeerate and maxburnamount are supported only on Syscoin") - } - var err error +// SendRawTransactionWithOpts forwards optional Syscoin sendrawtransaction +// parameters through the metrics wrapper when the backend supports them. +// +// SYSCOIN +func (c *blockChainWithMetrics) SendRawTransactionWithOpts(p bchain.SendRawTransactionParams) (v string, err error) { defer func(s time.Time) { c.observeRPCLatency("SendRawTransactionWithOpts", s, err) }(time.Now()) - return o.SendRawTransactionWithOpts(p) + if sender, ok := c.b.(bchain.SendRawTransactionOpts); ok { + return sender.SendRawTransactionWithOpts(p) + } + return c.b.SendRawTransaction(p.Hex, p.DisableAlternativeRPC) } func (c *blockChainWithMetrics) GetMempoolEntry(txid string) (v *bchain.MempoolEntry, err error) { @@ -298,6 +346,14 @@ func (c *blockChainWithMetrics) GetMempoolEntry(txid string) (v *bchain.MempoolE return c.b.GetMempoolEntry(txid) } +// GetSPVProof forwards Syscoin's bridge SPV proof RPC through metrics. +// +// SYSCOIN +func (c *blockChainWithMetrics) GetSPVProof(hash string) (v json.RawMessage, err error) { + defer func(s time.Time) { c.observeRPCLatency("GetSPVProof", s, err) }(time.Now()) + return c.b.GetSPVProof(hash) +} + func (c *blockChainWithMetrics) GetChainParser() bchain.BlockChainParser { return c.b.GetChainParser() } @@ -307,9 +363,9 @@ func (c *blockChainWithMetrics) EthereumTypeGetBalance(addrDesc bchain.AddressDe return c.b.EthereumTypeGetBalance(addrDesc) } -func (c *blockChainWithMetrics) EthereumTypeGetNonce(addrDesc bchain.AddressDescriptor) (v uint64, err error) { - defer func(s time.Time) { c.observeRPCLatency("EthereumTypeGetNonce", s, err) }(time.Now()) - return c.b.EthereumTypeGetNonce(addrDesc) +func (c *blockChainWithMetrics) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool) (pending uint64, confirmed uint64, confirmedOK bool, err error) { + defer func(s time.Time) { c.observeRPCLatency("EthereumTypeGetNonces", s, err) }(time.Now()) + return c.b.EthereumTypeGetNonces(addrDesc, withConfirmed) } func (c *blockChainWithMetrics) EthereumTypeEstimateGas(params map[string]interface{}) (v uint64, err error) { @@ -317,36 +373,77 @@ func (c *blockChainWithMetrics) EthereumTypeEstimateGas(params map[string]interf return c.b.EthereumTypeEstimateGas(params) } -func (c *blockChainWithMetrics) EthereumTypeGetErc20ContractInfo(contractDesc bchain.AddressDescriptor) (v *bchain.Erc20Contract, err error) { - defer func(s time.Time) { c.observeRPCLatency("EthereumTypeGetErc20ContractInfo", s, err) }(time.Now()) - return c.b.EthereumTypeGetErc20ContractInfo(contractDesc) +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) } func (c *blockChainWithMetrics) EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc bchain.AddressDescriptor) (v *big.Int, err error) { - defer func(s time.Time) { c.observeRPCLatency("EthereumTypeGetErc20ContractInfo", s, err) }(time.Now()) + defer func(s time.Time) { c.observeRPCLatency("EthereumTypeGetErc20ContractBalance", s, err) }(time.Now()) return c.b.EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc) } -func (c *blockChainWithMetrics) GetChainTips() (result string, err error) { - defer func(s time.Time) { c.observeRPCLatency("GetChainTips", s, err) }(time.Now()) - result, err = c.b.GetChainTips() - return result, err +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) } -func (c *blockChainWithMetrics) GetSPVProof(hash string) (result string, err error) { - defer func(s time.Time) { c.observeRPCLatency("GetSPVProof", s, err) }(time.Now()) - result, err = c.b.GetSPVProof(hash) - return result, err +// 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) FetchNEVMAssetDetails(assetGuid uint64) (result *bchain.Asset, err error) { - defer func(s time.Time) { c.observeRPCLatency("FetchNEVMAssetDetails", s, err) }(time.Now()) - result, err = c.b.FetchNEVMAssetDetails(assetGuid) - return result, err +func (c *blockChainWithMetrics) EthereumTypeGetSupportedStakingPools() []string { + return c.b.EthereumTypeGetSupportedStakingPools() } -func (c *blockChainWithMetrics) GetContractExplorerBaseURL() string { - return c.b.GetContractExplorerBaseURL() +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 { @@ -354,6 +451,17 @@ type mempoolWithMetrics struct { 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 { @@ -363,8 +471,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)) } @@ -386,11 +512,86 @@ func (c *mempoolWithMetrics) GetAllEntries() (v bchain.MempoolTxidEntries) { return c.mempool.GetAllEntries() } +// GetTxAssets forwards Syscoin SPT asset mempool lookups through the metrics +// wrapper. +// +// SYSCOIN +func (c *mempoolWithMetrics) GetTxAssets(assetGuid uint64) (v bchain.MempoolTxidEntries) { + defer func(s time.Time) { c.observeRPCLatency("GetTxAssets", s, nil) }(time.Now()) + return c.mempool.GetTxAssets(assetGuid) +} + func (c *mempoolWithMetrics) GetTransactionTime(txid string) uint32 { return c.mempool.GetTransactionTime(txid) } -func (c *mempoolWithMetrics) GetTxAssets(assetGuid uint64) bchain.MempoolTxidEntries { - defer func(s time.Time) { c.observeRPCLatency("GetTxAssets", s, nil) }(time.Now()) - return c.mempool.GetTxAssets(assetGuid) +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") +} + +// AverageBlockTimeDuration forwards the wrapped chain's configured block cadence +// so blockbook.go's duck-typed lookup reaches it through the metrics wrapper. +// Returns an error when the underlying chain doesn't expose one. +func (c *blockChainWithMetrics) AverageBlockTimeDuration() (time.Duration, error) { + if p, ok := c.b.(interface { + AverageBlockTimeDuration() (time.Duration, error) + }); ok { + return p.AverageBlockTimeDuration() + } + return 0, errors.New("average block time not supported by underlying chain") +} + +// MissingBlockRetryOverride forwards the wrapped chain's per-chain sync-worker +// retry override through the metrics wrapper; nil when none is provided. +func (c *blockChainWithMetrics) MissingBlockRetryOverride() *bchain.MissingBlockRetry { + if p, ok := c.b.(interface { + MissingBlockRetryOverride() *bchain.MissingBlockRetry + }); ok { + return p.MissingBlockRetryOverride() + } + return nil +} + +// FetchNEVMAssetDetails forwards Syscoin's optional NEVM asset metadata lookup +// through the metrics wrapper. +// +// SYSCOIN +func (c *blockChainWithMetrics) FetchNEVMAssetDetails(assetGuid uint64) (v *bchain.Asset, err error) { + if p, ok := c.b.(interface { + FetchNEVMAssetDetails(uint64) (*bchain.Asset, error) + }); ok { + defer func(s time.Time) { c.observeRPCLatency("FetchNEVMAssetDetails", s, err) }(time.Now()) + return p.FetchNEVMAssetDetails(assetGuid) + } + return nil, errors.New("FetchNEVMAssetDetails not supported by underlying chain") +} + +// GetContractExplorerBaseURL forwards Syscoin's optional NEVM explorer URL. +// +// SYSCOIN +func (c *blockChainWithMetrics) GetContractExplorerBaseURL() string { + if p, ok := c.b.(interface { + GetContractExplorerBaseURL() string + }); ok { + return p.GetContractExplorerBaseURL() + } + return "" } 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 7e36c61d6b..6024a11444 100644 --- a/bchain/coins/btc/bitcoinlikeparser.go +++ b/bchain/coins/btc/bitcoinlikeparser.go @@ -12,7 +12,6 @@ import ( "unicode/utf8" vlq "github.com/bsm/go-vlq" - "github.com/golang/glog" "github.com/juju/errors" "github.com/martinboehm/btcd/blockchain" "github.com/martinboehm/btcd/btcec" @@ -21,7 +20,7 @@ import ( "github.com/martinboehm/btcutil/chaincfg" "github.com/martinboehm/btcutil/hdkeychain" "github.com/martinboehm/btcutil/txscript" - "github.com/syscoin/blockbook/bchain" + "github.com/trezor/blockbook/bchain" ) // OutputScriptToAddressesFunc converts ScriptPubKey to bitcoin addresses @@ -36,6 +35,7 @@ type BitcoinLikeParser struct { XPubMagicSegwitP2sh uint32 XPubMagicSegwitNative uint32 Slip44 uint32 + VSizeSupport bool minimumCoinbaseConfirmations int } @@ -45,6 +45,7 @@ func NewBitcoinLikeParser(params *chaincfg.Params, c *Configuration) *BitcoinLik BaseParser: &bchain.BaseParser{ BlockAddressesToKeep: c.BlockAddressesToKeep, AmountDecimalPoint: 8, + AddressAliases: c.AddressAliases, }, Params: params, XPubMagic: c.XPubMagic, @@ -204,6 +205,14 @@ func (p *BitcoinLikeParser) outputScriptToAddresses(script []byte) ([]string, bo // TxFromMsgTx converts bitcoin wire Tx to bchain.Tx func (p *BitcoinLikeParser) TxFromMsgTx(t *wire.MsgTx, parseAddresses bool) bchain.Tx { + var vSize int64 + if p.VSizeSupport { + baseSize := t.SerializeSizeStripped() + totalSize := t.SerializeSize() + weight := int64((baseSize * (blockchain.WitnessScaleFactor - 1)) + totalSize) + vSize = (weight + (blockchain.WitnessScaleFactor - 1)) / blockchain.WitnessScaleFactor + } + vin := make([]bchain.Vin, len(t.TxIn)) for i, in := range t.TxIn { if blockchain.IsCoinBaseTx(t) { @@ -222,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)) @@ -248,6 +258,7 @@ func (p *BitcoinLikeParser) TxFromMsgTx(t *wire.MsgTx, parseAddresses bool) bcha Txid: t.TxHash().String(), Version: t.Version, LockTime: t.LockTime, + VSize: vSize, Vin: vin, Vout: vout, // skip: BlockHash, @@ -286,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(), }, @@ -320,6 +332,11 @@ func (p *BitcoinLikeParser) MinimumCoinbaseConfirmations() int { return p.minimumCoinbaseConfirmations } +// SupportsVSize returns true if vsize of a transaction should be computed and returned by API +func (p *BitcoinLikeParser) SupportsVSize() bool { + return p.VSizeSupport +} + var tapTweakTagHash = sha256.Sum256([]byte("TapTweak")) func tapTweakHash(msg []byte) []byte { @@ -424,7 +441,11 @@ 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+))/\*)?\)+`) + var err error + xpubDesriptorRegex, err = 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+))/\*)?\)+(#[a-z0-9]{8})?$`) + if err != nil { + panic(errors.Annotate(err, "Invalid bitcoinparser xpubDesriptorRegex")) + } typeSubexpIndex = xpubDesriptorRegex.SubexpIndex("type") bipSubexpIndex = xpubDesriptorRegex.SubexpIndex("bip") xpubSubexpIndex = xpubDesriptorRegex.SubexpIndex("xpub") @@ -485,6 +506,9 @@ func (p *BitcoinLikeParser) ParseXpub(xpub string) (*bchain.XpubDescriptor, erro if len(changes) == 0 { return nil, errors.New("Invalid xpub descriptor, cannot parse change") } + if len(changes) > bchain.MaxXpubChangeIndexes { + return nil, errors.Errorf("Xpub descriptor change index count exceeds limit %d", bchain.MaxXpubChangeIndexes) + } descriptor.ChangeIndexes = make([]uint32, len(changes)) for i, ch := range changes { change, err := strconv.ParseUint(ch, 10, 32) @@ -564,224 +588,3 @@ func (p *BitcoinLikeParser) DerivationBasePath(descriptor *bchain.XpubDescriptor } return "m/" + descriptor.Bip + "'/" + strconv.Itoa(int(p.Slip44)) + "'/" + c, nil } -func (p *BitcoinLikeParser) PackAddrBalance(ab *bchain.AddrBalance, buf, varBuf []byte) []byte { - buf = buf[:0] - l := p.BaseParser.PackVaruint(uint(ab.Txs), varBuf) - buf = append(buf, varBuf[:l]...) - l = p.BaseParser.PackBigint(&ab.SentSat, varBuf) - buf = append(buf, varBuf[:l]...) - l = p.BaseParser.PackBigint(&ab.BalanceSat, varBuf) - buf = append(buf, varBuf[:l]...) - for _, utxo := range ab.Utxos { - // if Vout < 0, utxo is marked as spent - if utxo.Vout >= 0 { - buf = append(buf, utxo.BtxID...) - l = p.BaseParser.PackVaruint(uint(utxo.Vout), varBuf) - buf = append(buf, varBuf[:l]...) - l = p.BaseParser.PackVaruint(uint(utxo.Height), varBuf) - buf = append(buf, varBuf[:l]...) - l = p.BaseParser.PackBigint(&utxo.ValueSat, varBuf) - buf = append(buf, varBuf[:l]...) - } - } - return buf -} - -func (p *BitcoinLikeParser) UnpackAddrBalance(buf []byte, txidUnpackedLen int, detail bchain.AddressBalanceDetail) (*bchain.AddrBalance, error) { - txs, l := p.BaseParser.UnpackVaruint(buf) - sentSat, sl := p.BaseParser.UnpackBigint(buf[l:]) - balanceSat, bl := p.BaseParser.UnpackBigint(buf[l+sl:]) - l = l + sl + bl - ab := &bchain.AddrBalance{ - Txs: uint32(txs), - SentSat: sentSat, - BalanceSat: balanceSat, - } - - if detail != bchain.AddressBalanceDetailNoUTXO { - // estimate the size of utxos to avoid reallocation - ab.Utxos = make([]bchain.Utxo, 0, len(buf[l:])/txidUnpackedLen+3) - // ab.UtxosMap = make(map[string]int, cap(ab.Utxos)) - for len(buf[l:]) >= txidUnpackedLen+3 { - btxID := append([]byte(nil), buf[l:l+txidUnpackedLen]...) - l += txidUnpackedLen - vout, ll := p.BaseParser.UnpackVaruint(buf[l:]) - l += ll - height, ll := p.BaseParser.UnpackVaruint(buf[l:]) - l += ll - valueSat, ll := p.BaseParser.UnpackBigint(buf[l:]) - l += ll - u := bchain.Utxo{ - BtxID: btxID, - Vout: int32(vout), - Height: uint32(height), - ValueSat: valueSat, - } - if detail == bchain.AddressBalanceDetailUTXO { - ab.Utxos = append(ab.Utxos, u) - } else { - ab.AddUtxo(&u) - } - } - } - return ab, nil -} - -func (p *BitcoinLikeParser) PackTxAddresses(ta *bchain.TxAddresses, buf []byte, varBuf []byte) []byte { - buf = buf[:0] - l := p.BaseParser.PackVaruint(uint(ta.Height), varBuf) - buf = append(buf, varBuf[:l]...) - l = p.BaseParser.PackVaruint(uint(len(ta.Inputs)), varBuf) - buf = append(buf, varBuf[:l]...) - for i := range ta.Inputs { - buf = p.AppendTxInput(&ta.Inputs[i], buf, varBuf) - } - l = p.BaseParser.PackVaruint(uint(len(ta.Outputs)), varBuf) - buf = append(buf, varBuf[:l]...) - for i := range ta.Outputs { - buf = p.AppendTxOutput(&ta.Outputs[i], buf, varBuf) - } - return buf -} - -func (p *BitcoinLikeParser) UnpackTxAddresses(buf []byte) (*bchain.TxAddresses, error) { - ta := bchain.TxAddresses{} - height, l := p.BaseParser.UnpackVaruint(buf) - ta.Height = uint32(height) - inputs, ll := p.BaseParser.UnpackVaruint(buf[l:]) - l += ll - ta.Inputs = make([]bchain.TxInput, inputs) - for i := uint(0); i < inputs; i++ { - l += p.UnpackTxInput(&ta.Inputs[i], buf[l:]) - } - outputs, ll := p.BaseParser.UnpackVaruint(buf[l:]) - l += ll - ta.Outputs = make([]bchain.TxOutput, outputs) - for i := uint(0); i < outputs; i++ { - l += p.UnpackTxOutput(&ta.Outputs[i], buf[l:]) - } - return &ta, nil -} - -func (p *BitcoinLikeParser) AppendTxInput(txi *bchain.TxInput, buf []byte, varBuf []byte) []byte { - la := len(txi.AddrDesc) - l := p.BaseParser.PackVaruint(uint(la), varBuf) - buf = append(buf, varBuf[:l]...) - buf = append(buf, txi.AddrDesc...) - l = p.BaseParser.PackBigint(&txi.ValueSat, varBuf) - buf = append(buf, varBuf[:l]...) - return buf -} - -func (p *BitcoinLikeParser) AppendTxOutput(txo *bchain.TxOutput, buf []byte, varBuf []byte) []byte { - la := len(txo.AddrDesc) - if txo.Spent { - la = ^la - } - l := p.BaseParser.PackVarint(la, varBuf) - buf = append(buf, varBuf[:l]...) - buf = append(buf, txo.AddrDesc...) - l = p.BaseParser.PackBigint(&txo.ValueSat, varBuf) - buf = append(buf, varBuf[:l]...) - return buf -} - - -func (p *BitcoinLikeParser) UnpackTxInput(ti *bchain.TxInput, buf []byte) int { - al, l := p.BaseParser.UnpackVaruint(buf) - ti.AddrDesc = append([]byte(nil), buf[l:l+int(al)]...) - al += uint(l) - ti.ValueSat, l = p.BaseParser.UnpackBigint(buf[al:]) - return l + int(al) -} - -func (p *BitcoinLikeParser) UnpackTxOutput(to *bchain.TxOutput, buf []byte) int { - al, l := p.BaseParser.UnpackVarint(buf) - if al < 0 { - to.Spent = true - al = ^al - } - to.AddrDesc = append([]byte(nil), buf[l:l+al]...) - al += l - to.ValueSat, l = p.BaseParser.UnpackBigint(buf[al:]) - return l + al -} - -func (p *BitcoinLikeParser) PackOutpoints(outpoints []bchain.DbOutpoint) []byte { - buf := make([]byte, 0, 32) - bvout := make([]byte, vlq.MaxLen32) - for _, o := range outpoints { - l := p.BaseParser.PackVarint32(o.Index, bvout) - buf = append(buf, []byte(o.BtxID)...) - buf = append(buf, bvout[:l]...) - } - return buf -} - -func (p *BitcoinLikeParser) UnpackNOutpoints(buf []byte) ([]bchain.DbOutpoint, int, error) { - txidUnpackedLen := p.BaseParser.PackedTxidLen() - n, m := p.BaseParser.UnpackVaruint(buf) - outpoints := make([]bchain.DbOutpoint, n) - for i := uint(0); i < n; i++ { - if m+txidUnpackedLen >= len(buf) { - return nil, 0, errors.New("Inconsistent data in UnpackNOutpoints") - } - btxID := append([]byte(nil), buf[m:m+txidUnpackedLen]...) - m += txidUnpackedLen - vout, voutLen := p.BaseParser.UnpackVarint32(buf[m:]) - m += voutLen - outpoints[i] = bchain.DbOutpoint{ - BtxID: btxID, - Index: vout, - } - } - return outpoints, m, nil -} - -// Block index - -func (p *BitcoinLikeParser) PackBlockInfo(block *bchain.DbBlockInfo) ([]byte, error) { - packed := make([]byte, 0, 64) - varBuf := make([]byte, vlq.MaxLen64) - b, err := p.BaseParser.PackBlockHash(block.Hash) - if err != nil { - return nil, err - } - pl := p.BaseParser.PackedTxidLen() - if len(b) != pl { - glog.Warning("Non standard block hash for height ", block.Height, ", hash [", block.Hash, "]") - if len(b) > pl { - b = b[:pl] - } else { - b = append(b, make([]byte, pl-len(b))...) - } - } - packed = append(packed, b...) - packed = append(packed, p.BaseParser.PackUint(uint32(block.Time))...) - l := p.BaseParser.PackVaruint(uint(block.Txs), varBuf) - packed = append(packed, varBuf[:l]...) - l = p.BaseParser.PackVaruint(uint(block.Size), varBuf) - packed = append(packed, varBuf[:l]...) - return packed, nil -} - -func (p *BitcoinLikeParser) UnpackBlockInfo(buf []byte) (*bchain.DbBlockInfo, error) { - pl := p.BaseParser.PackedTxidLen() - // minimum length is PackedTxidLen + 4 bytes time + 1 byte txs + 1 byte size - if len(buf) < pl+4+2 { - return nil, nil - } - txid, err := p.BaseParser.UnpackBlockHash(buf[:pl]) - if err != nil { - return nil, err - } - t := p.BaseParser.UnpackUint(buf[pl:]) - txs, l := p.BaseParser.UnpackVaruint(buf[pl+4:]) - size, _ := p.BaseParser.UnpackVaruint(buf[pl+4+l:]) - return &bchain.DbBlockInfo{ - Hash: txid, - Time: int64(t), - Txs: uint32(txs), - Size: uint32(size), - }, nil -} diff --git a/bchain/coins/btc/bitcoinparser.go b/bchain/coins/btc/bitcoinparser.go index 69b49645e4..d746e82619 100644 --- a/bchain/coins/btc/bitcoinparser.go +++ b/bchain/coins/btc/bitcoinparser.go @@ -6,24 +6,24 @@ import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/common" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" ) // temp params for signet(wait btcd commit) // magic numbers const ( - SignetMagic wire.BitcoinNet = 0x6a70c7f0 + Testnet4Magic wire.BitcoinNet = 0x283f161c ) // chain parameters var ( - SigNetParams chaincfg.Params + TestNet4Params chaincfg.Params ) func init() { - SigNetParams = chaincfg.TestNet3Params - SigNetParams.Net = SignetMagic + TestNet4Params = chaincfg.TestNet3Params + TestNet4Params.Net = Testnet4Magic } // BitcoinParser handle @@ -33,9 +33,11 @@ type BitcoinParser struct { // NewBitcoinParser returns new BitcoinParser instance func NewBitcoinParser(params *chaincfg.Params, c *Configuration) *BitcoinParser { - return &BitcoinParser{ + p := &BitcoinParser{ BitcoinLikeParser: NewBitcoinLikeParser(params, c), } + p.VSizeSupport = true + return p } // GetChainParams contains network parameters for the main Bitcoin network, @@ -48,8 +50,12 @@ func GetChainParams(chain string) *chaincfg.Params { switch chain { case "test": return &chaincfg.TestNet3Params + case "testnet4": + return &TestNet4Params case "regtest": return &chaincfg.RegressionNetParams + case "signet": + return &chaincfg.SigNetParams } return &chaincfg.MainNetParams } @@ -74,10 +80,16 @@ 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"` Vout []Vout `json:"vout"` BlockHeight uint32 `json:"blockHeight,omitempty"` @@ -101,8 +113,9 @@ 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 tx.BlockHeight = bitcoinTx.BlockHeight tx.Confirmations = bitcoinTx.Confirmations @@ -130,4 +143,4 @@ func (p *BitcoinParser) ParseTxFromJson(msg json.RawMessage) (*bchain.Tx, error) } return &tx, nil -} \ No newline at end of file +} diff --git a/bchain/coins/btc/bitcoinparser_test.go b/bchain/coins/btc/bitcoinparser_test.go index 30f1b7060d..4f5c5c2228 100644 --- a/bchain/coins/btc/bitcoinparser_test.go +++ b/bchain/coins/btc/bitcoinparser_test.go @@ -7,10 +7,12 @@ import ( "math/big" "os" "reflect" + "strconv" + "strings" "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" + "github.com/trezor/blockbook/bchain" ) func TestMain(m *testing.M) { @@ -19,6 +21,22 @@ func TestMain(m *testing.M) { os.Exit(c) } +func testChangeList(count int, separator string) string { + changes := make([]string, count) + for i := range changes { + changes[i] = strconv.Itoa(i) + } + return strings.Join(changes, separator) +} + +func testChangeIndexes(count int) []uint32 { + indexes := make([]uint32, count) + for i := range indexes { + indexes[i] = uint32(i) + } + return indexes +} + func TestGetAddrDescFromAddress(t *testing.T) { type args struct { address string @@ -467,10 +485,12 @@ func TestGetAddressesFromAddrDescTestnet(t *testing.T) { } var ( - testTx1, testTx2 bchain.Tx + testTx1, testTx2, testTx3, testTx4 bchain.Tx testTxPacked1 = "0001e2408ba8d7af5401000000017f9a22c9cbf54bd902400df746f138f37bcf5b4d93eb755820e974ba43ed5f42040000006a4730440220037f4ed5427cde81d55b9b6a2fd08c8a25090c2c2fff3a75c1a57625ca8a7118022076c702fe55969fa08137f71afd4851c48e31082dd3c40c919c92cdbc826758d30121029f6da5623c9f9b68a9baf9c1bc7511df88fa34c6c2f71f7c62f2f03ff48dca80feffffff019c9700000000000017a9146144d57c8aff48492c9dfb914e120b20bad72d6f8773d00700" testTxPacked2 = "0007c91a899ab7da6a010000000001019d64f0c72a0d206001decbffaa722eb1044534c74eee7a5df8318e42a4323ec10000000017160014550da1f5d25a9dae2eafd6902b4194c4c6500af6ffffffff02809698000000000017a914cd668d781ece600efa4b2404dc91fd26b8b8aed8870553d7360000000017a914246655bdbd54c7e477d0ea2375e86e0db2b8f80a8702473044022076aba4ad559616905fa51d4ddd357fc1fdb428d40cb388e042cdd1da4a1b7357022011916f90c712ead9a66d5f058252efd280439ad8956a967e95d437d246710bc9012102a80a5964c5612bb769ef73147b2cf3c149bc0fd4ecb02f8097629c94ab013ffd00000000" + testTxPacked3 = "00003d818bfda9aa3e02000000000102deb1999a857ab0a13d6b12fbd95ea75b409edde5f2ff747507ce42d9986a8b9d0000000000fdffffff9fd2d3361e203b2375eba6438efbef5b3075531e7e583c7cc76b7294fe7f22980000000000fdffffff02a0860100000000001600148091746745464e7555c31e9a5afceac14a02978ae7fc1c0000000000160014565ea9ff4589d3e05ba149ae6e257752bfdc2a1e0247304402207d67d320a8e813f986b35e9791935fcb736754812b7038686f5de6cfdcda99cd02201c3bb2c178e0056016437ecfe365a7eef84aa9d293ebdc566177af82e22fcdd3012103abb30c1bbe878b07b58dc169b1d061d48c60be8107f632a59778b38bf7ceea5a02473044022044f54a478cfe086e870cb026c9dcd4e14e63778bef569a4d55a6332725cd9a9802202f0e94c04e6f328fc64ad9efe552888c299750d1b8d033324825a3ff29920e030121036fcd433428aa7dc65c4f5408fa31f208c54fe4b4c6c1ae9c39a825ed4f1ac039813d0000" + testTxPacked4 = "0000a2b98ced82b6400300000000010148f8f93ebb12407809920d2ab9cc1bf01289b314eb23028c83fdab21e5fefa690100000000fdffffff0150c3000000000000160014cb888de3c89670a3061fb6ef6590f187649cca060247304402206a9db8d7157e4b0a06a1f090b9de88cdc616028b431b80617a055117877e479a02202937d6d1658d4a8afde86b245325c3bb0e769a87cb09d802bcefaa21550065e201210374aa8f312de4ebccbef55609700a39764387aa4ff5d76f1ccb4d2382e454f05b00000000" ) func init() { @@ -479,6 +499,7 @@ func init() { Blocktime: 1519053802, Txid: "056e3d82e5ffd0e915fb9b62797d76263508c34fe3e5dbed30dd3e943930f204", LockTime: 512115, + VSize: 189, Version: 1, Vin: []bchain.Vin{ { @@ -509,6 +530,7 @@ func init() { Blocktime: 1235678901, Txid: "474e6795760ebe81cb4023dc227e5a0efe340e1771c89a0035276361ed733de7", LockTime: 0, + VSize: 166, Version: 1, Vin: []bchain.Vin{ { @@ -543,6 +565,86 @@ func init() { }, }, } + + testTx3 = bchain.Tx{ + Hex: "02000000000102deb1999a857ab0a13d6b12fbd95ea75b409edde5f2ff747507ce42d9986a8b9d0000000000fdffffff9fd2d3361e203b2375eba6438efbef5b3075531e7e583c7cc76b7294fe7f22980000000000fdffffff02a0860100000000001600148091746745464e7555c31e9a5afceac14a02978ae7fc1c0000000000160014565ea9ff4589d3e05ba149ae6e257752bfdc2a1e0247304402207d67d320a8e813f986b35e9791935fcb736754812b7038686f5de6cfdcda99cd02201c3bb2c178e0056016437ecfe365a7eef84aa9d293ebdc566177af82e22fcdd3012103abb30c1bbe878b07b58dc169b1d061d48c60be8107f632a59778b38bf7ceea5a02473044022044f54a478cfe086e870cb026c9dcd4e14e63778bef569a4d55a6332725cd9a9802202f0e94c04e6f328fc64ad9efe552888c299750d1b8d033324825a3ff29920e030121036fcd433428aa7dc65c4f5408fa31f208c54fe4b4c6c1ae9c39a825ed4f1ac039813d0000", + Blocktime: 1607805599, + Txid: "24551a58a1d1fb89d7052e2bbac7cb69a7825ee1e39439befbec8c32148cf735", + LockTime: 15745, + VSize: 208, + Version: 2, + Vin: []bchain.Vin{ + { + ScriptSig: bchain.ScriptSig{ + Hex: "", + }, + Txid: "9d8b6a98d942ce077574fff2e5dd9e405ba75ed9fb126b3da1b07a859a99b1de", + Vout: 0, + Sequence: 4294967293, + }, + { + ScriptSig: bchain.ScriptSig{ + Hex: "", + }, + Txid: "98227ffe94726bc77c3c587e1e5375305beffb8e43a6eb75233b201e36d3d29f", + Vout: 0, + Sequence: 4294967293, + }, + }, + Vout: []bchain.Vout{ + { + ValueSat: *big.NewInt(100000), + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "00148091746745464e7555c31e9a5afceac14a02978a", + Addresses: []string{ + "tb1qszghge69ge8824wrr6d94l82c99q99u2ccgv5w", + }, + }, + }, + { + ValueSat: *big.NewInt(1899751), + N: 1, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "0014565ea9ff4589d3e05ba149ae6e257752bfdc2a1e", + Addresses: []string{ + "tb1q2e02nl6938f7qkapfxhxufth22lac2s792vsxp", + }, + }, + }, + }, + } + + 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) { @@ -580,6 +682,28 @@ func TestPackTx(t *testing.T) { want: testTxPacked2, wantErr: false, }, + { + name: "signet-1", + args: args{ + tx: testTx3, + height: 15745, + blockTime: 1607805599, + parser: NewBitcoinParser(GetChainParams("signet"), &Configuration{}), + }, + 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) { @@ -628,6 +752,26 @@ func TestUnpackTx(t *testing.T) { want1: 510234, wantErr: false, }, + { + name: "signet-1", + args: args{ + packedTx: testTxPacked3, + parser: NewBitcoinParser(GetChainParams("signet"), &Configuration{}), + }, + want: &testTx3, + 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) { @@ -637,6 +781,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) } @@ -651,11 +799,12 @@ func TestParseXpubDescriptors(t *testing.T) { btcMainParser := NewBitcoinParser(GetChainParams("main"), &Configuration{XPubMagic: 76067358, XPubMagicSegwitP2sh: 77429938, XPubMagicSegwitNative: 78792518}) btcTestnetParser := NewBitcoinParser(GetChainParams("test"), &Configuration{XPubMagic: 70617039, XPubMagicSegwitP2sh: 71979618, XPubMagicSegwitNative: 73342198}) tests := []struct { - name string - xpub string - parser *BitcoinParser - want *bchain.XpubDescriptor - wantErr bool + name string + xpub string + parser *BitcoinParser + want *bchain.XpubDescriptor + wantErr bool + wantErrContains string }{ { name: "tpub", @@ -693,6 +842,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", @@ -705,6 +866,32 @@ func TestParseXpubDescriptors(t *testing.T) { ChangeIndexes: []uint32{0, 1, 2}, }, }, + { + name: "tr([5c9e228d/86'/1'/0']tpubD/{max changes}/*)#4rqwxvej", + xpub: "tr([5c9e228d/86'/1'/0']tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN/{" + testChangeList(bchain.MaxXpubChangeIndexes, ",") + "}/*)#4rqwxvej", + parser: btcTestnetParser, + want: &bchain.XpubDescriptor{ + XpubDescriptor: "tr([5c9e228d/86'/1'/0']tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN/{" + testChangeList(bchain.MaxXpubChangeIndexes, ",") + "}/*)#4rqwxvej", + Xpub: "tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN", + Type: bchain.P2TR, + Bip: "86", + ChangeIndexes: testChangeIndexes(bchain.MaxXpubChangeIndexes), + }, + }, + { + name: "tr([5c9e228d/86'/1'/0']tpubD/{too many changes}/*)#4rqwxvej error - change list limit", + xpub: "tr([5c9e228d/86'/1'/0']tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN/{" + testChangeList(bchain.MaxXpubChangeIndexes+1, ",") + "}/*)#4rqwxvej", + parser: btcTestnetParser, + wantErr: true, + wantErrContains: "change index count exceeds limit", + }, + { + name: "tr([5c9e228d/86'/1'/0']tpubD//*)#4rqwxvej error - change list limit", + xpub: "tr([5c9e228d/86'/1'/0']tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN/<" + testChangeList(bchain.MaxXpubChangeIndexes+1, ";") + ">/*)#4rqwxvej", + parser: btcTestnetParser, + wantErr: true, + wantErrContains: "change index count exceeds limit", + }, { name: "tr([5c9e228d/86'/1'/0']tpubD/3/*)#4rqwxvej", xpub: "tr([5c9e228d/86'/1'/0']tpubDC88gkaZi5HvJGxGDNLADkvtdpni3mLmx6vr2KnXmWMG8zfkBRggsxHVBkUpgcwPe2KKpkyvTJCdXHb1UHEWE64vczyyPQfHr1skBcsRedN/3/*)#4rqwxvej", @@ -813,6 +1000,12 @@ func TestParseXpubDescriptors(t *testing.T) { ChangeIndexes: []uint32{0, 1}, }, }, + { + name: "wpkh(xpub) error - trailing JS suffix", + xpub: `wpkh(xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ)"});alert(1);//`, + parser: btcMainParser, + wantErr: true, + }, { name: "xxx(xpub) error - unknown output script", xpub: "xxx(xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ)", @@ -839,6 +1032,9 @@ func TestParseXpubDescriptors(t *testing.T) { t.Errorf("ParseXpub() error = %v, wantErr %v", err, tt.wantErr) return } + if err != nil && tt.wantErrContains != "" && !strings.Contains(err.Error(), tt.wantErrContains) { + t.Errorf("ParseXpub() error = %v, want error containing %q", err, tt.wantErrContains) + } if err == nil { if got.ExtKey == nil { t.Errorf("ParseXpub() got nil ExtKey") @@ -1170,3 +1366,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 7d61010b73..e5fbcc2c34 100644 --- a/bchain/coins/btc/bitcoinrpc.go +++ b/bchain/coins/btc/bitcoinrpc.go @@ -5,34 +5,63 @@ import ( "context" "encoding/hex" "encoding/json" - "io" - "io/ioutil" "math/big" "net" "net/http" - "runtime/debug" "time" "github.com/golang/glog" "github.com/juju/errors" "github.com/martinboehm/btcd/wire" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/common" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" ) // 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 + // callCtx is the base context for all RPC HTTP requests; Shutdown cancels it + // so an in-flight sync call aborts promptly instead of running to the client + // timeout (which would otherwise delay process shutdown by up to that timeout). + callCtx context.Context + cancelCall context.CancelFunc + 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 +} + +// AverageBlockTimeDuration exposes the chain's nominal block cadence so the +// blockbook_average_block_time_seconds gauge can normalize tip-age alerts +// across coins. Returns an error if the config didn't set averageBlockTimeMs. +func (b *BitcoinRPC) AverageBlockTimeDuration() (time.Duration, error) { + return b.ChainConfig.AverageBlockTimeDuration() +} + +// MissingBlockRetryOverride exposes the per-chain sync-worker retry override +// (or nil to use built-in defaults). Consumed by blockbook.go at SyncWorker +// construction via a duck-typed interface assertion. +func (b *BitcoinRPC) MissingBlockRetryOverride() *bchain.MissingBlockRetry { + if b.ChainConfig == nil { + return nil + } + return b.ChainConfig.MissingBlockRetry } // Configuration represents json config file @@ -43,12 +72,14 @@ type Configuration struct { RPCUser string `json:"rpc_user"` RPCPass string `json:"rpc_pass"` RPCTimeout int `json:"rpc_timeout"` + AddressAliases bool `json:"address_aliases,omitempty"` Parse bool `json:"parse"` MessageQueueBinding string `json:"message_queue_binding"` Subversion string `json:"subversion"` 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"` @@ -59,11 +90,36 @@ type Configuration struct { AlternativeEstimateFee string `json:"alternative_estimate_fee,omitempty"` AlternativeEstimateFeeParams string `json:"alternative_estimate_fee_params,omitempty"` MinimumCoinbaseConfirmations int `json:"minimumCoinbaseConfirmations,omitempty"` - // SYSCOIN - Web3RPCURL string `json:"web3_rpc_url,omitempty"` - Web3RPCURLBackup string `json:"web3_rpc_url_backup,omitempty"` - Web3Explorer string `json:"web3_explorer_url,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"` + // SYSCOIN: NEVM RPC endpoints used for SPT metadata enrichment. + Web3RPCURL string `json:"web3_rpc_url,omitempty"` + Web3RPCURLBackup string `json:"web3_rpc_url_backup,omitempty"` + Web3Explorer string `json:"web3_explorer_url,omitempty"` + // AverageBlockTimeMs is the chain's nominal block cadence in ms. + // Optional on UTXO chains; when set it is exposed as the + // blockbook_average_block_time_seconds gauge for alert normalization. + AverageBlockTimeMs int `json:"averageBlockTimeMs,omitempty"` + // MissingBlockRetry overrides the sync-worker missing-block retry policy + // per chain. All fields are optional; missing fields use built-in defaults. + MissingBlockRetry *bchain.MissingBlockRetry `json:"missingBlockRetry,omitempty"` +} + +// AverageBlockTimeDuration returns AverageBlockTimeMs as a time.Duration. +// Returns an error when unset so callers can distinguish "no configured cadence" +// from a real zero — matching the EVM Configuration helper. +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 +} + +// defaultRPCTimeoutSeconds is used when rpc_timeout is unset or non-positive. +// A zero http.Client.Timeout means no timeout at all, so a blocked backend could +// hang a sync RPC (and thus shutdown) forever; a finite floor is enforced instead. +const defaultRPCTimeoutSeconds = 15 // NewBitcoinRPC returns new BitcoinRPC instance. func NewBitcoinRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { @@ -88,31 +144,56 @@ 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 + if c.RPCTimeout <= 0 { + glog.Warningf("rpc_timeout=%d is invalid, using default %d seconds", c.RPCTimeout, defaultRPCTimeoutSeconds) + c.RPCTimeout = defaultRPCTimeoutSeconds + } + transport := &http.Transport{ - Dial: (&net.Dialer{KeepAlive: 600 * time.Second}).Dial, + // DialContext (not Dial) so a request context cancelled by Shutdown also + // interrupts a blocked TCP connect, not just an established request. + DialContext: (&net.Dialer{KeepAlive: 600 * time.Second}).DialContext, MaxIdleConns: 100, MaxIdleConnsPerHost: 100, // necessary to not to deplete ports } 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, + } + s.callCtx, s.cancelCall = context.WithCancel(context.Background()) return s, nil } +// requestContext returns the base context for RPC HTTP requests. Shutdown cancels +// it so in-flight calls abort promptly. Falls back to context.Background() when +// unset (e.g. a directly-constructed test instance). +func (b *BitcoinRPC) requestContext() context.Context { + if b.callCtx != nil { + return b.callCtx + } + return context.Background() +} + // Initialize initializes BitcoinRPC instance. func (b *BitcoinRPC) Initialize() error { b.ChainConfig.SupportsEstimateFee = false @@ -140,11 +221,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.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.ChainConfig.AlternativeEstimateFee = "" + 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 @@ -153,32 +253,52 @@ 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) - if err != nil { - glog.Error("mq: ", err) - return err - } - b.mq = mq + + if b.mq != nil { + return nil + } + if b.ChainConfig.MessageQueueBinding == "" { + glog.Warning("ZeroMQ subscription disabled: message_queue_binding is empty; relying on polling") + return nil + } + + 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 } + b.mq = mq + return nil } // Shutdown ZeroMQ and other resources func (b *BitcoinRPC) Shutdown(ctx context.Context) error { + // Cancel in-flight RPC HTTP requests so a sync call cannot delay shutdown up to + // the client timeout. Covers every coin that reaches the backend through Call + // (all BitcoinRPC-embedding coins that do not run their own HTTP client). + if b.cancelCall != nil { + b.cancelCall() + } if b.mq != nil { if err := b.mq.Shutdown(ctx); err != nil { glog.Error("MQ.Shutdown error: ", err) @@ -351,40 +471,32 @@ type ResGetRawTransactionNonverbose struct { Result string `json:"result"` } -// decoderawtransaction -type ResDecodeRawTransaction struct { +// SYSCOIN: syscoingetspvproof passthrough for bridge consumers. +type ResGetSPVProof struct { Error *bchain.RPCError `json:"error"` Result json.RawMessage `json:"result"` } -type CmdDecodeRawTransaction struct { +type CmdGetSPVProof struct { Method string `json:"method"` Params struct { - Hex string `json:"hexstring"` + Txid string `json:"txid"` } `json:"params"` } -// getchaintips -type ResGetChainTips struct { - Error *bchain.RPCError `json:"error"` - Result json.RawMessage `json:"result"` -} -type CmdGetChainTips struct { - Method string `json:"method"` +type rpcBatchRequest struct { + JSONRPC string `json:"jsonrpc,omitempty"` + ID int `json:"id"` + Method string `json:"method"` + Params []interface{} `json:"params,omitempty"` } -// syscoingetspvproof -type ResGetSPVProof struct { - Error *bchain.RPCError `json:"error"` +type rpcBatchResponse struct { + ID int `json:"id"` Result json.RawMessage `json:"result"` + Error *bchain.RPCError `json:"error"` } -type CmdGetSPVProof struct { - Method string `json:"method"` - Params struct { - Txid string `json:"txid"` - } `json:"params"` -} // estimatesmartfee type CmdEstimateSmartFee struct { @@ -420,8 +532,8 @@ type ResEstimateFee struct { // sendrawtransaction type CmdSendRawTransaction struct { - Method string `json:"method"` - Params []interface{} `json:"params"` + Method string `json:"method"` + Params []string `json:"params"` } type ResSendRawTransaction struct { @@ -523,7 +635,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. @@ -759,6 +872,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,10 +973,10 @@ func (b *BitcoinRPC) GetTransaction(txid string) (*bchain.Tx, error) { return nil, err } tx, err := b.Parser.ParseTxFromJson(r) - tx.CoinSpecificData = r if err != nil { return nil, errors.Annotatef(err, "txid %v", txid) } + tx.CoinSpecificData = r return tx, nil } @@ -803,31 +1010,7 @@ func (b *BitcoinRPC) getRawTransaction(txid string) (json.RawMessage, error) { return res.Result, nil } -// getRawTransaction returns json as returned by backend, with all coin specific data -func (b *BitcoinRPC) DecodeRawTransaction(hex string) (string, error) { - glog.V(1).Info("rpc: decodeRawTransaction ", hex) - - res := ResDecodeRawTransaction{} - req := CmdDecodeRawTransaction{Method: "decoderawtransaction"} - req.Params.Hex = hex - err := b.Call(&req, &res) - - if err != nil { - return "", errors.Annotatef(err, "hex %v", hex) - } - if res.Error != nil { - return "", errors.Annotatef(res.Error, "hex %v", hex) - } - rawMarshal, err := json.Marshal(&res.Result) - if err != nil { - return "", err - } - decodedRawString := string(rawMarshal) - return decodedRawString, 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) @@ -844,7 +1027,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 @@ -859,8 +1041,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) @@ -871,9 +1076,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 } @@ -887,29 +1091,43 @@ func (b *BitcoinRPC) EstimateFee(blocks int) (big.Int, error) { return r, nil } -func nonEmptyPtr(s *string) bool { - return s != nil && *s != "" +// 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, disableAlternativeRPC bool) (string, error) { + return SendRawTransactionWithParams(b, bchain.SendRawTransactionParams{Hex: tx, DisableAlternativeRPC: disableAlternativeRPC}) } -// SendRawTransactionWithParams calls sendrawtransaction with optional Core-compatible -// maxfeerate / maxburnamount. Package-level function so types embedding BitcoinRPC do not -// promote it as SendRawTransactionOpts (only SyscoinRPC implements that interface). +// SendRawTransactionWithParams sends raw transaction with optional Bitcoin-like +// parameters. +// +// SYSCOIN: Syscoin Core uses the third sendrawtransaction parameter as +// maxburnamount for governance collateral broadcasts. func SendRawTransactionWithParams(b *BitcoinRPC, p bchain.SendRawTransactionParams) (string, error) { glog.V(1).Info("rpc: sendrawtransaction") - params := []interface{}{p.Hex} - if nonEmptyPtr(p.MaxFeeRate) { - params = append(params, *p.MaxFeeRate) - } - if nonEmptyPtr(p.MaxBurnAmount) { - if !nonEmptyPtr(p.MaxFeeRate) { - return "", errors.New("maxfeerate is required when maxburnamount is set") + res := ResSendRawTransaction{} + req := CmdSendRawTransaction{Method: "sendrawtransaction"} + req.Params = []string{p.Hex} + if p.MaxFeeRate != nil { + req.Params = append(req.Params, *p.MaxFeeRate) + if p.MaxBurnAmount != nil { + req.Params = append(req.Params, *p.MaxBurnAmount) } - params = append(params, *p.MaxBurnAmount) } - - res := ResSendRawTransaction{} - req := CmdSendRawTransaction{Method: "sendrawtransaction", Params: params} err := b.Call(&req, &res) if err != nil { @@ -921,11 +1139,6 @@ func SendRawTransactionWithParams(b *BitcoinRPC, p bchain.SendRawTransactionPara return res.Result, nil } -// SendRawTransaction sends raw transaction -func (b *BitcoinRPC) SendRawTransaction(tx string) (string, error) { - return SendRawTransactionWithParams(b, bchain.SendRawTransactionParams{Hex: tx}) -} - // GetMempoolEntry returns mempool data for given transaction func (b *BitcoinRPC) GetMempoolEntry(txid string) (*bchain.MempoolEntry, error) { glog.V(1).Info("rpc: getmempoolentry") @@ -953,24 +1166,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.NewRequestWithContext(b.requestContext(), "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 @@ -979,7 +1204,7 @@ func (b *BitcoinRPC) Call(req interface{}, res interface{}) error { if err != nil { return err } - httpReq, err := http.NewRequest("POST", b.rpcURL, bytes.NewBuffer(httpData)) + httpReq, err := http.NewRequestWithContext(b.requestContext(), "POST", b.rpcURL, bytes.NewBuffer(httpData)) if err != nil { return err } @@ -996,11 +1221,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_shutdown_test.go b/bchain/coins/btc/bitcoinrpc_shutdown_test.go new file mode 100644 index 0000000000..3ca6077e16 --- /dev/null +++ b/bchain/coins/btc/bitcoinrpc_shutdown_test.go @@ -0,0 +1,67 @@ +package btc + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +// TestShutdownAbortsInFlightCall verifies that Shutdown cancels an in-flight RPC +// HTTP request promptly via the per-client base context, rather than letting it +// run to the (much longer) client timeout — which would otherwise delay process +// shutdown by up to that timeout. This is the shared seam that aborts sync RPCs +// for every BitcoinRPC-embedding coin reaching the backend through Call, and the +// same mechanism Tron threads into its HTTP node clients. +func TestShutdownAbortsInFlightCall(t *testing.T) { + var startedOnce sync.Once + started := make(chan struct{}) + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + startedOnce.Do(func() { close(started) }) + // Hold the request open until the client cancels it (Shutdown) or the test ends. + select { + case <-r.Context().Done(): + case <-release: + } + })) + defer srv.Close() + defer close(release) + + ctx, cancel := context.WithCancel(context.Background()) + b := &BitcoinRPC{ + // A long client timeout makes the test fail (block ~30s) if cancellation regresses. + client: http.Client{Timeout: 30 * time.Second}, + rpcURL: srv.URL, + RPCMarshaler: JSONMarshalerV2{}, + callCtx: ctx, + cancelCall: cancel, + } + + errCh := make(chan error, 1) + go func() { + _, err := b.GetBestBlockHash() + errCh <- err + }() + + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the RPC request to reach the server") + } + + if err := b.Shutdown(context.Background()); err != nil { + t.Fatalf("Shutdown: %v", err) + } + + select { + case err := <-errCh: + if err == nil { + t.Fatal("expected GetBestBlockHash to fail after Shutdown, got nil") + } + case <-time.After(3 * time.Second): + t.Fatal("GetBestBlockHash did not return after Shutdown; in-flight call was not cancelled") + } +} 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 cc4eb155ed..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/syscoin/blockbook/bchain" + "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 917a7bcbbd..8055c7d6e3 100644 --- a/bchain/coins/btg/bgoldparser.go +++ b/bchain/coins/btg/bgoldparser.go @@ -8,9 +8,9 @@ import ( "github.com/martinboehm/btcd/chaincfg/chainhash" "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) const ( @@ -52,7 +52,9 @@ type BGoldParser struct { // NewBGoldParser returns new BGoldParser instance func NewBGoldParser(params *chaincfg.Params, c *btc.Configuration) *BGoldParser { - return &BGoldParser{BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c)} + p := &BGoldParser{BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c)} + p.VSizeSupport = true + return p } // GetChainParams contains network parameters for the main Bitcoin Cash network, @@ -81,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 @@ -105,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, }, @@ -112,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 18127f0ba6..0a8f430a96 100644 --- a/bchain/coins/btg/bgoldparser_test.go +++ b/bchain/coins/btg/bgoldparser_test.go @@ -12,7 +12,7 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -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/btg/bgoldrpc.go b/bchain/coins/btg/bgoldrpc.go index 88a8128fe1..b40bfcba3c 100644 --- a/bchain/coins/btg/bgoldrpc.go +++ b/bchain/coins/btg/bgoldrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // BGoldRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/cpuchain/cpuchainparser.go b/bchain/coins/cpuchain/cpuchainparser.go index e9a49d98c0..fdc767eca7 100644 --- a/bchain/coins/cpuchain/cpuchainparser.go +++ b/bchain/coins/cpuchain/cpuchainparser.go @@ -3,7 +3,7 @@ package cpuchain import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/btc" ) // magic numbers diff --git a/bchain/coins/cpuchain/cpuchainrpc.go b/bchain/coins/cpuchain/cpuchainrpc.go index 8503d4ba0a..0b06369811 100644 --- a/bchain/coins/cpuchain/cpuchainrpc.go +++ b/bchain/coins/cpuchain/cpuchainrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // CPUchainRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/dash/dashparser.go b/bchain/coins/dash/dashparser.go index 6d4a6c5026..d98e26a566 100644 --- a/bchain/coins/dash/dashparser.go +++ b/bchain/coins/dash/dashparser.go @@ -3,8 +3,8 @@ package dash import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) const ( diff --git a/bchain/coins/dash/dashparser_test.go b/bchain/coins/dash/dashparser_test.go index d8b096f365..3d6c872689 100644 --- a/bchain/coins/dash/dashparser_test.go +++ b/bchain/coins/dash/dashparser_test.go @@ -12,8 +12,8 @@ import ( "reflect" "testing" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) type testBlock struct { @@ -159,7 +159,7 @@ var ( }, }, } - testTxPacked1 = "0a20ed732a404cdfd4e0475a7a016200b7eef191f2c9de0ffdef8a20091c0499299c12e2010100000001f85264d11a747bdba77d411e5e4a3d35e3aeb5843b34a95234a2121ac65496bd000000006b483045022100dfa158fbd9773fab4f6f329c807e040af0c3a40967cbe01667169b914ed5ad960220061c5876364caa3e3c9c990ad2b4cc8b1a53d4f954dbda8434b0e67cc8348ff6012103093865e1e132b33a2a5ed01c79d2edba3473826a66cb26b8311bfa42749c2190ffffffff02ec3f8a2a010000001976a91470dcef2a22575d7a8f0779fb1d6cdd48135bd22788ac3116491d000000001976a91471348f7780e955a2a60eba17ecc4c826ebc23a9888ac0000000018f6cad8e305200028c0e03e3299010a001220bd9654c61a12a23452a9343b84b5aee3353d4a5e1e417da7db7b741ad16452f81800226b483045022100dfa158fbd9773fab4f6f329c807e040af0c3a40967cbe01667169b914ed5ad960220061c5876364caa3e3c9c990ad2b4cc8b1a53d4f954dbda8434b0e67cc8348ff6012103093865e1e132b33a2a5ed01c79d2edba3473826a66cb26b8311bfa42749c219028ffffffff0f3a480a05012a8a3fec10001a1976a91470dcef2a22575d7a8f0779fb1d6cdd48135bd22788ac2222586b7963425831796b565858733932704169365a51775a50457265396b5348484b483a470a041d49163110011a1976a91471348f7780e955a2a60eba17ecc4c826ebc23a9888ac2222586d31523974684b426d32455a4b5a657658736d4d5834445677515175546f685a754001" + testTxPacked1 = "0a20ed732a404cdfd4e0475a7a016200b7eef191f2c9de0ffdef8a20091c0499299c12e2010100000001f85264d11a747bdba77d411e5e4a3d35e3aeb5843b34a95234a2121ac65496bd000000006b483045022100dfa158fbd9773fab4f6f329c807e040af0c3a40967cbe01667169b914ed5ad960220061c5876364caa3e3c9c990ad2b4cc8b1a53d4f954dbda8434b0e67cc8348ff6012103093865e1e132b33a2a5ed01c79d2edba3473826a66cb26b8311bfa42749c2190ffffffff02ec3f8a2a010000001976a91470dcef2a22575d7a8f0779fb1d6cdd48135bd22788ac3116491d000000001976a91471348f7780e955a2a60eba17ecc4c826ebc23a9888ac0000000018f6cad8e30528c0e03e3295011220bd9654c61a12a23452a9343b84b5aee3353d4a5e1e417da7db7b741ad16452f8226b483045022100dfa158fbd9773fab4f6f329c807e040af0c3a40967cbe01667169b914ed5ad960220061c5876364caa3e3c9c990ad2b4cc8b1a53d4f954dbda8434b0e67cc8348ff6012103093865e1e132b33a2a5ed01c79d2edba3473826a66cb26b8311bfa42749c219028ffffffff0f3a460a05012a8a3fec1a1976a91470dcef2a22575d7a8f0779fb1d6cdd48135bd22788ac2222586b7963425831796b565858733932704169365a51775a50457265396b5348484b483a470a041d49163110011a1976a91471348f7780e955a2a60eba17ecc4c826ebc23a9888ac2222586d31523974684b426d32455a4b5a657658736d4d5834445677515175546f685a754001" testTx2 = bchain.Tx{ Blocktime: 1551246710, @@ -195,7 +195,7 @@ var ( }, } - testTxPacked2 = "0a2071d6975e3b79b52baf26c3269896a34f3bedfb04561c692ffa31f64dada1f9c412b50103000500010000000000000000000000000000000000000000000000000000000000000000ffffffff170340b00f1291af3c09542bc8349901000000002f4e614effffffff024181f809000000001976a9146a341485a9444b35dc9cb90d24e7483de7d37e0088ac3581f809000000001976a9140d1156f6026bf975ea3553b03fb534d0959c294c88ac0000000026010040b00f00000000000000000000000000000000000000000000000000000000000000000018f6cad8e305200028c0e03e32380a2e30333430623030663132393161663363303935343262633833343939303130303030303030303266346536313465180028ffffffff0f3a470a0409f8814110001a1976a9146a341485a9444b35dc9cb90d24e7483de7d37e0088ac2222586b4e507242534a7472485a5576557162334a46346735724d4233757a614a66454c3a470a0409f8813510011a1976a9140d1156f6026bf975ea3553b03fb534d0959c294c88ac222258627377505868634c716d35414e35677763545479695547535032596e6457776b394003" + testTxPacked2 = "0a2071d6975e3b79b52baf26c3269896a34f3bedfb04561c692ffa31f64dada1f9c412b50103000500010000000000000000000000000000000000000000000000000000000000000000ffffffff170340b00f1291af3c09542bc8349901000000002f4e614effffffff024181f809000000001976a9146a341485a9444b35dc9cb90d24e7483de7d37e0088ac3581f809000000001976a9140d1156f6026bf975ea3553b03fb534d0959c294c88ac0000000026010040b00f00000000000000000000000000000000000000000000000000000000000000000018f6cad8e30528c0e03e32360a2e3033343062303066313239316166336330393534326263383334393930313030303030303030326634653631346528ffffffff0f3a450a0409f881411a1976a9146a341485a9444b35dc9cb90d24e7483de7d37e0088ac2222586b4e507242534a7472485a5576557162334a46346735724d4233757a614a66454c3a470a0409f8813510011a1976a9140d1156f6026bf975ea3553b03fb534d0959c294c88ac222258627377505868634c716d35414e35677763545479695547535032596e6457776b394003" ) func TestBaseParser_ParseTxFromJson(t *testing.T) { diff --git a/bchain/coins/dash/dashrpc.go b/bchain/coins/dash/dashrpc.go index 3677644d37..f61578c028 100644 --- a/bchain/coins/dash/dashrpc.go +++ b/bchain/coins/dash/dashrpc.go @@ -5,8 +5,8 @@ import ( "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) const firstBlockWithSpecialTransactions = 1028160 @@ -27,7 +27,6 @@ func NewDashRPC(config json.RawMessage, pushHandler func(bchain.NotificationType b.(*btc.BitcoinRPC), } s.RPCMarshaler = btc.JSONMarshalerV1{} - s.ChainConfig.SupportsEstimateSmartFee = false return s, nil } diff --git a/bchain/coins/dcr/decredparser.go b/bchain/coins/dcr/decredparser.go index 86aa8b7d5f..b96cb964f1 100644 --- a/bchain/coins/dcr/decredparser.go +++ b/bchain/coins/dcr/decredparser.go @@ -19,9 +19,9 @@ import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/base58" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) const ( @@ -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/decredparser_test.go b/bchain/coins/dcr/decredparser_test.go index c445d42ea9..815bd71604 100644 --- a/bchain/coins/dcr/decredparser_test.go +++ b/bchain/coins/dcr/decredparser_test.go @@ -9,8 +9,8 @@ import ( "reflect" "testing" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) var ( diff --git a/bchain/coins/dcr/decredrpc.go b/bchain/coins/dcr/decredrpc.go index ba1f63ad0e..31c5810f81 100644 --- a/bchain/coins/dcr/decredrpc.go +++ b/bchain/coins/dcr/decredrpc.go @@ -19,9 +19,9 @@ import ( "github.com/decred/dcrd/dcrjson/v3" "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/common" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/common" ) // voteBitYes defines the vote bit set when a given block validates the previous @@ -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/deeponion/deeponionparser.go b/bchain/coins/deeponion/deeponionparser.go index e507dd7681..ed95fd2082 100644 --- a/bchain/coins/deeponion/deeponionparser.go +++ b/bchain/coins/deeponion/deeponionparser.go @@ -3,8 +3,8 @@ package deeponion import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // magic numbers diff --git a/bchain/coins/deeponion/deeponionparser_test.go b/bchain/coins/deeponion/deeponionparser_test.go index f499673515..2dbb494a11 100644 --- a/bchain/coins/deeponion/deeponionparser_test.go +++ b/bchain/coins/deeponion/deeponionparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -75,7 +75,7 @@ func Test_GetAddrDescFromAddress_Mainnet(t *testing.T) { var ( testTx1 bchain.Tx - testTxPacked1 = "0a206ba18524d81af732d0226ffdb63d2bcdc0d58a35ac97b5ad731057932d324e1412b401010000001134415d0114caae2bf9a7808aee0798e6245a347405d46c8131dbf55cbbbc689bbee367e902000000484730440220280f3fa80b4e93834fe0a8d9884105310eaa8d36d77b9aff113b6c498138e5bb02204578409f0a14fa1950ea4951314fd495fd503b42a6325efb5c139a6c8253912401ffffffff0200000000000000000005f22f5904000000232102bdb95d89f07e3a29305f3c8de86ec211ed77b7e15cf314c85c532a6b71c2ce07ac000000001891e884ea05200028b88a5432760a001220e967e3be9b68bcbb5cf5db31816cd40574345a24e69807ee8a80a7f92baeca14180222484730440220280f3fa80b4e93834fe0a8d9884105310eaa8d36d77b9aff113b6c498138e5bb02204578409f0a14fa1950ea4951314fd495fd503b42a6325efb5c139a6c825391240128ffffffff0f3a0210003a520a0504583af7fb10011a232102bdb95d89f07e3a29305f3c8de86ec211ed77b7e15cf314c85c532a6b71c2ce07ac2222446d343835624e4a6169474a6d4556746832426e5a345931796763756644736934454001" + testTxPacked1 = "0a206ba18524d81af732d0226ffdb63d2bcdc0d58a35ac97b5ad731057932d324e1412b401010000001134415d0114caae2bf9a7808aee0798e6245a347405d46c8131dbf55cbbbc689bbee367e902000000484730440220280f3fa80b4e93834fe0a8d9884105310eaa8d36d77b9aff113b6c498138e5bb02204578409f0a14fa1950ea4951314fd495fd503b42a6325efb5c139a6c8253912401ffffffff0200000000000000000005f22f5904000000232102bdb95d89f07e3a29305f3c8de86ec211ed77b7e15cf314c85c532a6b71c2ce07ac000000001891e884ea0528b88a5432741220e967e3be9b68bcbb5cf5db31816cd40574345a24e69807ee8a80a7f92baeca14180222484730440220280f3fa80b4e93834fe0a8d9884105310eaa8d36d77b9aff113b6c498138e5bb02204578409f0a14fa1950ea4951314fd495fd503b42a6325efb5c139a6c825391240128ffffffff0f3a003a520a0504583af7fb10011a232102bdb95d89f07e3a29305f3c8de86ec211ed77b7e15cf314c85c532a6b71c2ce07ac2222446d343835624e4a6169474a6d4556746832426e5a345931796763756644736934454001" ) func init() { diff --git a/bchain/coins/deeponion/deeponionrpc.go b/bchain/coins/deeponion/deeponionrpc.go index 8185013b39..8ae14d779f 100644 --- a/bchain/coins/deeponion/deeponionrpc.go +++ b/bchain/coins/deeponion/deeponionrpc.go @@ -5,8 +5,8 @@ import ( "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // DeepOnionRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/digibyte/digibyteparser.go b/bchain/coins/digibyte/digibyteparser.go index 56cff1cdf1..705fee114f 100644 --- a/bchain/coins/digibyte/digibyteparser.go +++ b/bchain/coins/digibyte/digibyteparser.go @@ -3,7 +3,7 @@ package digibyte import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/btc" ) // network constants @@ -39,7 +39,9 @@ type DigiByteParser struct { // NewDigiByteParser returns new DigiByteParser instance func NewDigiByteParser(params *chaincfg.Params, c *btc.Configuration) *DigiByteParser { - return &DigiByteParser{BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c)} + p := &DigiByteParser{BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c)} + p.VSizeSupport = true + return p } // GetChainParams contains network parameters for the main DigiByte network diff --git a/bchain/coins/digibyte/digibyteparser_test.go b/bchain/coins/digibyte/digibyteparser_test.go index 25f5e8b29f..6ffe10cb00 100644 --- a/bchain/coins/digibyte/digibyteparser_test.go +++ b/bchain/coins/digibyte/digibyteparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -90,6 +90,7 @@ func init() { Blocktime: 1532239774, Txid: "0dcf2530419b9ef525a69f6a15e4d699be1dc9a4ac643c9581b6c57acf25eabf", LockTime: 7000000, + VSize: 226, Version: 1, Vin: []bchain.Vin{ { diff --git a/bchain/coins/digibyte/digibyterpc.go b/bchain/coins/digibyte/digibyterpc.go index 07bcc7ff6d..e435490f9c 100644 --- a/bchain/coins/digibyte/digibyterpc.go +++ b/bchain/coins/digibyte/digibyterpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // DigiByteRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/divi/diviparser.go b/bchain/coins/divi/diviparser.go index f2593230fd..405486005d 100755 --- a/bchain/coins/divi/diviparser.go +++ b/bchain/coins/divi/diviparser.go @@ -10,9 +10,9 @@ import ( "github.com/juju/errors" "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) const ( @@ -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/divi/diviparser_test.go b/bchain/coins/divi/diviparser_test.go index 60758ea12a..1f95e025ba 100755 --- a/bchain/coins/divi/diviparser_test.go +++ b/bchain/coins/divi/diviparser_test.go @@ -14,8 +14,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -105,11 +105,11 @@ func Test_GetAddressesFromAddrDesc(t *testing.T) { var ( // Mint transaction testTx1 bchain.Tx - testTxPacked1 = "0a20f7a5324866ba18058ab032196f34458d19f7ec5a4ac284670c3ef07bfa724644124201000000010000000000000000000000000000000000000000000000000000000000000000ffffffff0603de3d060101ffffffff010000000000000000000000000018aefd9ce905200028defb1832160a0c303364653364303630313031180028ffffffff0f3a0210004000" + testTxPacked1 = "0a20f7a5324866ba18058ab032196f34458d19f7ec5a4ac284670c3ef07bfa724644124201000000010000000000000000000000000000000000000000000000000000000000000000ffffffff0603de3d060101ffffffff010000000000000000000000000018aefd9ce90528defb1832140a0c30336465336430363031303128ffffffff0f3a00" // Normal transaction testTx2 bchain.Tx - testTxPacked2 = "0a20eace41778a2940ff423b72a42033990eb5d6092810734a5806da6f3e5b34086412ea010100000001084b029489e1cddf726080c447c8a2b1d4bbe43024db31b8b19bc07585db9555010000006a473044022017422b9e3414d6233fa75f9eb7778469bebbb40686b0f7eb77d90a04c80149610220411f1063086fe205ea821ceb0de89e8158e202aba00f5ebb92b51f97381311fd012102ccb10a2f0603a0624b8708abefb5f4700631fc131c5de38b51e0359e2ffa7d1cffffffff03000000000000000000f260de1a580100001976a9145b1d583a4c270f2f14be77b298f0a9c6df97471388ac009ca6920c0000001976a914cb1196fb1b98d04b0cb8d2ffde3c2de3eb83d9fe88ac0000000018aefd9ce905200028defb183298010a0012205595db8575c09bb1b831db2430e4bbd4b1a2c847c4806072dfcde18994024b081801226a473044022017422b9e3414d6233fa75f9eb7778469bebbb40686b0f7eb77d90a04c80149610220411f1063086fe205ea821ceb0de89e8158e202aba00f5ebb92b51f97381311fd012102ccb10a2f0603a0624b8708abefb5f4700631fc131c5de38b51e0359e2ffa7d1c28ffffffff0f3a0210003a490a0601581ade60f210011a1976a9145b1d583a4c270f2f14be77b298f0a9c6df97471388ac222244445373426368576956667650566e364c6470316e4c376b344c3737635344714d373a480a050c92a69c0010021a1976a914cb1196fb1b98d04b0cb8d2ffde3c2de3eb83d9fe88ac2222445065706e4d6b614e484b436136635169376f425468726469464577535359467a764000" + testTxPacked2 = "0a20eace41778a2940ff423b72a42033990eb5d6092810734a5806da6f3e5b34086412ea010100000001084b029489e1cddf726080c447c8a2b1d4bbe43024db31b8b19bc07585db9555010000006a473044022017422b9e3414d6233fa75f9eb7778469bebbb40686b0f7eb77d90a04c80149610220411f1063086fe205ea821ceb0de89e8158e202aba00f5ebb92b51f97381311fd012102ccb10a2f0603a0624b8708abefb5f4700631fc131c5de38b51e0359e2ffa7d1cffffffff03000000000000000000f260de1a580100001976a9145b1d583a4c270f2f14be77b298f0a9c6df97471388ac009ca6920c0000001976a914cb1196fb1b98d04b0cb8d2ffde3c2de3eb83d9fe88ac0000000018aefd9ce90528defb1832960112205595db8575c09bb1b831db2430e4bbd4b1a2c847c4806072dfcde18994024b081801226a473044022017422b9e3414d6233fa75f9eb7778469bebbb40686b0f7eb77d90a04c80149610220411f1063086fe205ea821ceb0de89e8158e202aba00f5ebb92b51f97381311fd012102ccb10a2f0603a0624b8708abefb5f4700631fc131c5de38b51e0359e2ffa7d1c28ffffffff0f3a003a490a0601581ade60f210011a1976a9145b1d583a4c270f2f14be77b298f0a9c6df97471388ac222244445373426368576956667650566e364c6470316e4c376b344c3737635344714d373a480a050c92a69c0010021a1976a914cb1196fb1b98d04b0cb8d2ffde3c2de3eb83d9fe88ac2222445065706e4d6b614e484b436136635169376f425468726469464577535359467a76" ) func init() { diff --git a/bchain/coins/divi/divirpc.go b/bchain/coins/divi/divirpc.go index 42d5c7120f..8f88009da3 100755 --- a/bchain/coins/divi/divirpc.go +++ b/bchain/coins/divi/divirpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // DivicoinRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/dogecoin/dogecoinparser.go b/bchain/coins/dogecoin/dogecoinparser.go index 385687d86c..743fea106b 100644 --- a/bchain/coins/dogecoin/dogecoinparser.go +++ b/bchain/coins/dogecoin/dogecoinparser.go @@ -5,19 +5,21 @@ import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) // magic numbers const ( MainnetMagic wire.BitcoinNet = 0xc0c0c0c0 + TestnetMagic wire.BitcoinNet = 0xfcc1b7dc // See https://github.com/dogecoin/dogecoin/blob/f80bfe9068ac1a0619d48dad0d268894d926941e/qa/rpc-tests/test_framework/mininode.py#L1620 ) // chain parameters var ( MainNetParams chaincfg.Params + TestNetParams chaincfg.Params ) func init() { @@ -25,6 +27,11 @@ func init() { MainNetParams.Net = MainnetMagic MainNetParams.PubKeyHashAddrID = []byte{30} MainNetParams.ScriptHashAddrID = []byte{22} + + TestNetParams = chaincfg.TestNet3Params + TestNetParams.Net = TestnetMagic + TestNetParams.PubKeyHashAddrID = []byte{113} // See https://github.com/dogecoin/dogecoin/blob/f80bfe9068ac1a0619d48dad0d268894d926941e/contrib/testgen/gen_base58_test_vectors.py#L23 + TestNetParams.ScriptHashAddrID = []byte{196} // See https://github.com/dogecoin/dogecoin/blob/f80bfe9068ac1a0619d48dad0d268894d926941e/contrib/testgen/gen_base58_test_vectors.py#L24 } // DogecoinParser handle @@ -42,11 +49,16 @@ func NewDogecoinParser(params *chaincfg.Params, c *btc.Configuration) *DogecoinP func GetChainParams(chain string) *chaincfg.Params { if !chaincfg.IsRegistered(&MainNetParams) { err := chaincfg.Register(&MainNetParams) + if err == nil { + err = chaincfg.Register(&TestNetParams) + } if err != nil { panic(err) } } switch chain { + case "test": + return &TestNetParams default: return &MainNetParams } @@ -80,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/dogecoin/dogecoinparser_test.go b/bchain/coins/dogecoin/dogecoinparser_test.go index 6be7abee10..c0cc969b0d 100644 --- a/bchain/coins/dogecoin/dogecoinparser_test.go +++ b/bchain/coins/dogecoin/dogecoinparser_test.go @@ -14,8 +14,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -82,6 +82,70 @@ func Test_GetAddrDescFromAddress_Mainnet(t *testing.T) { } } +func Test_GetAddrDescFromAddress_Testnet(t *testing.T) { + type args struct { + address string + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "P2PKH1", + args: args{address: "neYNjrnowpYrbS4QanPngXBi6MQcX6FsWV"}, + want: "76a9147183fdc10d664551d151922f95e58ab548d8ad2688ac", + wantErr: false, + }, + { + name: "P2PKH2", + args: args{address: "nYrWFiv3zz5MewmN6ZJUpEfLQwz63Ptf1i"}, + want: "76a9143320ff38bc2d44e404cc3f0b36202f3a4897e05088ac", + wantErr: false, + }, + { + name: "P2PKH3", + args: args{address: "nbn2EWCDp2xcb7jTxhcXytLKZuctY8xXiB"}, + want: "76a914533051d74f660325166fd342250f99fd366214ec88ac", + wantErr: false, + }, + { + name: "P2SH1", + args: args{address: "2MyChfh5WfqzDTyFibZq2uSF3WcYFE1G5te"}, + want: "a91441569cc9dbdc08a99d20079bfd12071a2bdbf8e987", + wantErr: false, + }, + { + name: "P2SH2", + args: args{address: "2NCnuCgdAAQHQvSQVw9eJA8UfbffupFLaYm"}, + want: "a914d66804cbba3b9035f2447b5454699f657dd3275087", + wantErr: false, + }, + { + name: "P2SH3", + args: args{address: "2N2ju8ukjDQbJRB4ptNtekDzYNiqSQHARvd"}, + want: "a9146825756d503c3a81659409636d6e6c40755fcdcf87", + wantErr: false, + }, + } + parser := NewDogecoinParser(GetChainParams("test"), &btc.Configuration{}) + + 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 !reflect.DeepEqual(h, tt.want) { + t.Errorf("GetAddrDescFromAddress() = %v, want %v", h, tt.want) + } + }) + } +} + func Test_GetAddressesFromAddrDesc_Mainnet(t *testing.T) { type args struct { script string @@ -157,12 +221,107 @@ func Test_GetAddressesFromAddrDesc_Mainnet(t *testing.T) { } } +func Test_GetAddressesFromAddrDesc_Testnet(t *testing.T) { + type args struct { + script string + } + tests := []struct { + name string + args args + want []string + want2 bool + wantErr bool + }{ + { + name: "P2PKH1", + args: args{script: "76a9147183fdc10d664551d151922f95e58ab548d8ad2688ac"}, + want: []string{"neYNjrnowpYrbS4QanPngXBi6MQcX6FsWV"}, + want2: true, + wantErr: false, + }, + { + name: "P2PKH2", + args: args{script: "76a9143320ff38bc2d44e404cc3f0b36202f3a4897e05088ac"}, + want: []string{"nYrWFiv3zz5MewmN6ZJUpEfLQwz63Ptf1i"}, + want2: true, + wantErr: false, + }, + { + name: "P2PKH3", + args: args{script: "76a914533051d74f660325166fd342250f99fd366214ec88ac"}, + want: []string{"nbn2EWCDp2xcb7jTxhcXytLKZuctY8xXiB"}, + want2: true, + wantErr: false, + }, + { + name: "P2SH1", + args: args{script: "a91441569cc9dbdc08a99d20079bfd12071a2bdbf8e987"}, + want: []string{"2MyChfh5WfqzDTyFibZq2uSF3WcYFE1G5te"}, + want2: true, + wantErr: false, + }, + { + name: "P2SH2", + args: args{script: "a914d66804cbba3b9035f2447b5454699f657dd3275087"}, + want: []string{"2NCnuCgdAAQHQvSQVw9eJA8UfbffupFLaYm"}, + want2: true, + wantErr: false, + }, + { + name: "P2SH3", + args: args{script: "a9146825756d503c3a81659409636d6e6c40755fcdcf87"}, + want: []string{"2N2ju8ukjDQbJRB4ptNtekDzYNiqSQHARvd"}, + want2: true, + wantErr: false, + }, + { + name: "OP_RETURN ascii", + args: args{script: "6a0c48656c6c6f20746865726521"}, + want: []string{"OP_RETURN (Hello there!)"}, + want2: false, + wantErr: false, + }, + { + name: "OP_RETURN hex", + args: args{script: "6a072020f1686f6a20"}, + want: []string{"OP_RETURN 2020f1686f6a20"}, + want2: false, + wantErr: false, + }, + } + + parser := NewDogecoinParser(GetChainParams("test"), &btc.Configuration{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, _ := hex.DecodeString(tt.args.script) + got, got2, 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) + } + if !reflect.DeepEqual(got2, tt.want2) { + t.Errorf("GetAddressesFromAddrDesc() = %v, want %v", got2, tt.want2) + } + }) + } +} + var ( testTx1 bchain.Tx testTxPacked1 = "00030e6d8ba8d7aa2001000000016b3c0c53267964120acf7f7e72217e3f463e52ce622f89659f6a6bb8e69a4d91000000006c493046022100a96454237e3a020994534583e28c04757881374bceac89f933ea9ff00b4db259022100fbb757ff7ea4f02c4e42556b2834c61eba1f1af605db089d836a0614d90a3b46012103cebdde6d1046e285df4f48497bc50dc20a4a258ca5b7308cb0a929c9fdadcd9dffffffff0217e823ca7f0200001976a914eef21768a546590993e313c7f3dfadf6a6efa1e888acaddf4cba010000001976a914e0fee2ea29dd9c6c759d8341bd0da4c4f738cced88ac00000000" testTx2 bchain.Tx testTxPacked2 = "0001193a8ba8d7835601000000016d0211b5656f1b8c2ac002445638e247082090ffc5d5fa7c38b445b84a2c2054000000006b4830450221008856f2f620df278c0fc6a5d5e2d50451c0a65a75aaf7a4a9cbfcac3918b5536802203dc685a784d49e2a95eb72763ad62f02094af78507c57b0a3c3f1d8a60f74db6012102db814cd43df584804fde1949365a6309714e342aef0794dc58385d7e413444cdffffffff0237daa2ee0a4715001976a9149355c01ed20057eac9fe0bbf8b07d87e62fe712d88ac8008389e7e8d03001976a9145b4f2511c94e4fcaa8f8835b2458f8cb6542ca7688ac00000000" + + testTx1_Testnet bchain.Tx + testTxPacked1_Testnet = "0030d40b8c8bc4d552010000000104207e905afaf6facfafe7f63cac0afaab07c36b8aab34d5a6b6402859cfbb3b010000006b483045022100daf5631310f5f8ffa4b6a5e9151e3eefbc797ab67ee52e78621526d32694705b02206c038bd527397d3d6f01cd72011a61b58f5d8b74c2d067274d09a8f70f436d330121031d716677bfa840265c32f02b1ff519e59b2d76fcc1753c59516d6891058e6826ffffffff0256b54009000000001976a914e56f55dbb600c10e1f4f6d5da38eea5e664504cf88ac220d5871030000001976a914e8600a08d053bcd41bc316be574efbba6e126d3088ac00000000" + + testTx2_Testnet bchain.Tx + testTxPacked2_Testnet = "0030d4068c8bc4c770010000000342dd48439e8fe54b161be919bd9a34e887fa88ca38b1200fc26f4edffc3e69950000000069463043021f48a06f76e53665563090919d01dab845039d7ca4050ea00d65dd6542f2e219022024ae5e8806d737c80e83aa969dd549afbf675a94582889b61a630c0a328969870121036a0a9600ef07072a55fdd770fe4c6e4a138ce3c409eb4328f37fb3066d12e598feffffff9883d80ffa7af541808640940b2a7fb69c8ad817ef51049cdf9ee09731460a12010000006a47304402201d6a14b9a2f275a64edfa447e13347e53023bc192de577a0f7fa5309f26d987b0220386ba58a20032395c8c773cc9be405251b9f5d47182f3be5055dbe01ce0894650121038548fe799340b63c4e05e1edee6a36c5a2b82abec4efecc60bef0205969fb9befeffffffd7d88db648983248f58adb62a428baad726e9d6f40a9dfbf8181c7cd013d2dad000000006b483045022100880a8e9c9fdf43ef8f8c2e6817327d6cee89f12e47d77eb6e470a1b2d99b695d0220755911c3ec6d6704c34d8fd447875c9dbaf37c83a2079352be827c558e1b3f3b012103ddd9ebd38c8891b2ee56b17a155367edcc251fc7aca30130523c834d94fd04d2feffffff021ed9092c000000001976a91433bc558a159810fe4353b09b38b270a74a02621f88ac29ebe9a50000000017a914d0e87f2ab081c1c00e3dca9b653d654889ddb7148705d43000" ) func init() { @@ -245,6 +404,102 @@ func init() { }, }, } + + testTx1_Testnet = bchain.Tx{ + Hex: "010000000104207e905afaf6facfafe7f63cac0afaab07c36b8aab34d5a6b6402859cfbb3b010000006b483045022100daf5631310f5f8ffa4b6a5e9151e3eefbc797ab67ee52e78621526d32694705b02206c038bd527397d3d6f01cd72011a61b58f5d8b74c2d067274d09a8f70f436d330121031d716677bfa840265c32f02b1ff519e59b2d76fcc1753c59516d6891058e6826ffffffff0256b54009000000001976a914e56f55dbb600c10e1f4f6d5da38eea5e664504cf88ac220d5871030000001976a914e8600a08d053bcd41bc316be574efbba6e126d3088ac00000000", + Blocktime: 1622709609, + Txid: "43cfbc6db77a8e9aad25913c2298da81421e513e216420b8af2562e744a030c9", + LockTime: 0, + Version: 1, + Vin: []bchain.Vin{ + { + ScriptSig: bchain.ScriptSig{ + Hex: "483045022100daf5631310f5f8ffa4b6a5e9151e3eefbc797ab67ee52e78621526d32694705b02206c038bd527397d3d6f01cd72011a61b58f5d8b74c2d067274d09a8f70f436d330121031d716677bfa840265c32f02b1ff519e59b2d76fcc1753c59516d6891058e6826", + }, + Txid: "3bbbcf592840b6a6d534ab8a6bc307abfa0aac3cf6e7afcffaf6fa5a907e2004", + Vout: 1, + Sequence: 4294967295, + }, + }, + Vout: []bchain.Vout{ + { + ValueSat: *big.NewInt(155235670), + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a914e56f55dbb600c10e1f4f6d5da38eea5e664504cf88ac", + Addresses: []string{ + "nq7JNvDtsGxB7wRjqc6DQ7JRzzXtHT2ETD", + }, + }, + }, + { + ValueSat: *big.NewInt(14786497826), + N: 1, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a914e8600a08d053bcd41bc316be574efbba6e126d3088ac", + Addresses: []string{ + "nqNr5hLREhQTjx4DA4TfJfeFR9YjMQXrxn", + }, + }, + }, + }, + } + + testTx2_Testnet = bchain.Tx{ + Hex: "010000000342dd48439e8fe54b161be919bd9a34e887fa88ca38b1200fc26f4edffc3e69950000000069463043021f48a06f76e53665563090919d01dab845039d7ca4050ea00d65dd6542f2e219022024ae5e8806d737c80e83aa969dd549afbf675a94582889b61a630c0a328969870121036a0a9600ef07072a55fdd770fe4c6e4a138ce3c409eb4328f37fb3066d12e598feffffff9883d80ffa7af541808640940b2a7fb69c8ad817ef51049cdf9ee09731460a12010000006a47304402201d6a14b9a2f275a64edfa447e13347e53023bc192de577a0f7fa5309f26d987b0220386ba58a20032395c8c773cc9be405251b9f5d47182f3be5055dbe01ce0894650121038548fe799340b63c4e05e1edee6a36c5a2b82abec4efecc60bef0205969fb9befeffffffd7d88db648983248f58adb62a428baad726e9d6f40a9dfbf8181c7cd013d2dad000000006b483045022100880a8e9c9fdf43ef8f8c2e6817327d6cee89f12e47d77eb6e470a1b2d99b695d0220755911c3ec6d6704c34d8fd447875c9dbaf37c83a2079352be827c558e1b3f3b012103ddd9ebd38c8891b2ee56b17a155367edcc251fc7aca30130523c834d94fd04d2feffffff021ed9092c000000001976a91433bc558a159810fe4353b09b38b270a74a02621f88ac29ebe9a50000000017a914d0e87f2ab081c1c00e3dca9b653d654889ddb7148705d43000", + Blocktime: 1622708728, + Txid: "91e2f3a9dde1e2da53f29c73033084b3d1a3b0c0ba6737d6418cfa9cad62be3c", + LockTime: 3200005, + Version: 1, + Vin: []bchain.Vin{ + { + ScriptSig: bchain.ScriptSig{ + Hex: "463043021f48a06f76e53665563090919d01dab845039d7ca4050ea00d65dd6542f2e219022024ae5e8806d737c80e83aa969dd549afbf675a94582889b61a630c0a328969870121036a0a9600ef07072a55fdd770fe4c6e4a138ce3c409eb4328f37fb3066d12e598", + }, + Txid: "95693efcdf4e6fc20f20b138ca88fa87e8349abd19e91b164be58f9e4348dd42", + Vout: 0, + Sequence: 4294967294, // Locktime is enabled for this transaction; see BIP-125 + }, + { + ScriptSig: bchain.ScriptSig{ + Hex: "47304402201d6a14b9a2f275a64edfa447e13347e53023bc192de577a0f7fa5309f26d987b0220386ba58a20032395c8c773cc9be405251b9f5d47182f3be5055dbe01ce0894650121038548fe799340b63c4e05e1edee6a36c5a2b82abec4efecc60bef0205969fb9be", + }, + Txid: "120a463197e09edf9c0451ef17d88a9cb67f2a0b9440868041f57afa0fd88398", + Vout: 1, + Sequence: 4294967294, + }, + { + ScriptSig: bchain.ScriptSig{ + Hex: "483045022100880a8e9c9fdf43ef8f8c2e6817327d6cee89f12e47d77eb6e470a1b2d99b695d0220755911c3ec6d6704c34d8fd447875c9dbaf37c83a2079352be827c558e1b3f3b012103ddd9ebd38c8891b2ee56b17a155367edcc251fc7aca30130523c834d94fd04d2", + }, + Txid: "ad2d3d01cdc78181bfdfa9406f9d6e72adba28a462db8af548329848b68dd8d7", + Vout: 0, + Sequence: 4294967294, + }, + }, + Vout: []bchain.Vout{ + { + ValueSat: *big.NewInt(738842910), + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a91433bc558a159810fe4353b09b38b270a74a02621f88ac", + Addresses: []string{ + "nYuiLjsDCVCBwZ3XhNv3qiiDiwmtQRLNhY", + }, + }, + }, + { + ValueSat: *big.NewInt(2783570729), + N: 1, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "a914d0e87f2ab081c1c00e3dca9b653d654889ddb71487", + Addresses: []string{ + "2NCHq4LmqvjB9mqrHMWNUjfDs2FdsmJah6b", + }, + }, + }, + }, + } } func Test_PackTx(t *testing.T) { @@ -298,6 +553,57 @@ func Test_PackTx(t *testing.T) { } } +func Test_PackTx_Testnet(t *testing.T) { + type args struct { + tx bchain.Tx + height uint32 + blockTime int64 + parser *DogecoinParser + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "dogecoin-testnet-1", + args: args{ + tx: testTx1_Testnet, + height: 3200011, + blockTime: 1622709609, + parser: NewDogecoinParser(GetChainParams("test"), &btc.Configuration{}), + }, + want: testTxPacked1_Testnet, + wantErr: false, + }, + { + name: "dogecoin-testnet-2", + args: args{ + tx: testTx2_Testnet, + height: 3200006, + blockTime: 1622708728, + parser: NewDogecoinParser(GetChainParams("test"), &btc.Configuration{}), + }, + want: testTxPacked2_Testnet, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.args.parser.PackTx(&tt.args.tx, tt.args.height, tt.args.blockTime) + if (err != nil) != tt.wantErr { + t.Errorf("packTx() error = %v, wantErr %v", err, tt.wantErr) + return + } + h := hex.EncodeToString(got) + if !reflect.DeepEqual(h, tt.want) { + t.Errorf("packTx() = %v, want %v", h, tt.want) + } + }) + } +} + func Test_UnpackTx(t *testing.T) { type args struct { packedTx string @@ -349,6 +655,57 @@ func Test_UnpackTx(t *testing.T) { } } +func Test_UnpackTx_Testnet(t *testing.T) { + type args struct { + packedTx string + parser *DogecoinParser + } + tests := []struct { + name string + args args + want *bchain.Tx + want1 uint32 + wantErr bool + }{ + { + name: "dogecoin-testnet-1", + args: args{ + packedTx: testTxPacked1_Testnet, + parser: NewDogecoinParser(GetChainParams("test"), &btc.Configuration{}), + }, + want: &testTx1_Testnet, + want1: 3200011, + wantErr: false, + }, + { + name: "dogecoin-testnet-2", + args: args{ + packedTx: testTxPacked2_Testnet, + parser: NewDogecoinParser(GetChainParams("test"), &btc.Configuration{}), + }, + want: &testTx2_Testnet, + want1: 3200006, + wantErr: false, + }, + } + for _, tt := range tests { + 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 + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("unpackTx() got = %v, want %v", got, tt.want) + } + if got1 != tt.want1 { + t.Errorf("unpackTx() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} + type testBlock struct { size int time int64 @@ -507,3 +864,96 @@ func TestParseBlock(t *testing.T) { } } } + +var testParseBlockTxs_Testnet = map[int]testBlock{ + // block without auxpow + 99999: { + size: 789, + time: 1401525639, + txs: []string{ + "a4006895a14f5eb8796c784e6845d6aaea57b87325ccd066e900dc53aa8bf6a4", + "63c8d9dff87bf56804748e33c6c69f66d4930c4403b01cfd9a9d520fb91e4e17", + "3fa993ca67bcd2b2dec15c61b7fc2947e268ebe71bcf77dc00236c99c15eaa72", + }, + }, + // 1st block with auxpow – see https://github.com/dogecoin/dogecoin/releases/tag/v1.8.0-beta-1 + 158100: { + size: 227, + time: 1407217902, + txs: []string{ + "f09d8c02cd8a3da1af2a9722860fc58c593ffb3b6c51ffe09f978c89744561a7", + }, + }, + // random block with auxpow + 1234580: { + size: 850, + time: 1521801579, + txs: []string{ + "b16bd42a4718fa8db2f88fd7f5d268582af1b9cfd378600ba4e615708d7550cf", + "2786caddf5c090be0554fe39511299b5daf5644024e80643c70c6573465252f1", + "423f4c0efa75a521a29c21100b90d4f6bdbafeb0778657154cedb638b9c3b439", + }, + }, + // recent block + 3274642: { + size: 847, + time: 1627931191, + txs: []string{ + "aefc849cd9522e6d93a0ef4c2647dec96386756f02238bb05507407a589fb9a9", + "877a2b2bf1f5b52e4893866aff3d573c49e22666662a510b0ff313bdf10e76b3", + "8c9130974b68a81c652f3b43c6d352b892d920fe498d1f2fb566c1155169f443", + "7fee19b12f19f426bc3e90b36d0149917695bcc4c57a07f0efebbdd30820a079", + }, + }, +} + +func helperLoadBlock_Testnet(t *testing.T, height int) []byte { + name := fmt.Sprintf("block_dump_testnet.%d", height) + path := filepath.Join("testdata", name) + + d, err := ioutil.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + d = bytes.TrimSpace(d) + + b := make([]byte, hex.DecodedLen(len(d))) + _, err = hex.Decode(b, d) + if err != nil { + t.Fatal(err) + } + + return b +} + +func TestParseBlock_Testnet(t *testing.T) { + p := NewDogecoinParser(GetChainParams("test"), &btc.Configuration{}) + + for height, tb := range testParseBlockTxs_Testnet { + b := helperLoadBlock_Testnet(t, height) + + blk, err := p.ParseBlock(b) + if err != nil { + t.Fatal(err) + } + + if blk.Size != tb.size { + t.Errorf("ParseBlock() block size: got %d, want %d", blk.Size, tb.size) + } + + if blk.Time != tb.time { + t.Errorf("ParseBlock() block time: got %d, want %d", blk.Time, tb.time) + } + + if len(blk.Txs) != len(tb.txs) { + t.Errorf("ParseBlock() number of transactions: got %d, want %d", len(blk.Txs), len(tb.txs)) + } + + for ti, tx := range tb.txs { + if blk.Txs[ti].Txid != tx { + t.Errorf("ParseBlock() transaction %d: got %s, want %s", ti, blk.Txs[ti].Txid, tx) + } + } + } +} diff --git a/bchain/coins/dogecoin/dogecoinrpc.go b/bchain/coins/dogecoin/dogecoinrpc.go index b496803ca2..5eafa7f4e9 100644 --- a/bchain/coins/dogecoin/dogecoinrpc.go +++ b/bchain/coins/dogecoin/dogecoinrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // DogecoinRPC is an interface to JSON-RPC dogecoind service. diff --git a/bchain/coins/dogecoin/testdata/block_dump_testnet.1234580 b/bchain/coins/dogecoin/testdata/block_dump_testnet.1234580 new file mode 100644 index 0000000000..ce050ec0cf --- /dev/null +++ b/bchain/coins/dogecoin/testdata/block_dump_testnet.1234580 @@ -0,0 +1 @@ +03006200560b52800fe10f45142aee16377dc241cb76b447bafac9306ef6cab77ca1cc02568951efc02537bf5ba1eae8fe2f95af5b2248bf608f4e0b3582a9f8bd6346716bd9b45affff0f1e53c70e000301000000010000000000000000000000000000000000000000000000000000000000000000ffffffff060394d6120103ffffffff0100d290e0e8000000232102fa293664fc4065b29664cafbf9c8639e32581a2385d29e3faf469eb7052ba47eac000000000100000001183be0d233276119160589c28feec31aa83cc2d933b96dc89f5e92669f51ff2901000000da00473044022036a000c0eea2e8c8682fa16bab07d523b4e26645635be54d328caf58e4b60d70022022b5f609e4593f86d244d76bda4f94ced3d32afeb7e2eed12c628e8c6f2c191b01483045022100e71355cdf6b5c22faedbc523bc6908e0fb689ca74ff72268bdadfdd210b58f2302204737733c92a54239ddb95ba1841771d70685a86d9f4cbe305d0050a2efdaf0d40147522103e3b553f7042c2f1d6af0182228135af1f817fb8e95f69f44093b0f0d216113a6210204d99f36750fe059e4b7128ba96482711b6c643ddcc25b15110325d5b28069eb52aeffffffff0200c2eb0b000000001976a9144d7c60f7ef28b638177d0ecc4909b69541573b4788ac006cedc78850000017a914ca07e851b2779d99dc7cd0345f70748e80c3aed8870000000001000000016f2ff9946d169b5bf70b88b6b31b4f49ef170f727f49d9b608d962269039bfe700000000da00483045022100d658cf8d45719462c3e33c996ada2bfcb9b7269987d2058c0e8aed7ef5a6721f02200ec61714951e4893aac6dd8819261cc701eacd296c61559c48951227792d2e730147304402203c0d99432c0637f614f1505bd9d996000afcc4df013286e24c6923015fa9e49e022031913f15620d7d992897882a856699be3e66b04c90b3dde7d33ccdc7b001f5d00147522103858c48d6fd45ddfed1dc5ce1b47e0eec36a87012a0865ac0fdca7a6a149184592102d8ad02f257f4cf3b68609babc56e9cd6cdde7f5e415ab1e8c839e1e0378a608b52aeffffffff020065cd1d0000000017a9145cf863be1eb54e7501235d16c4fbe378e38a3c75870084d7170000000017a9142587c36cfa4558cce983c4981e8da1357cac51608700000000 \ No newline at end of file diff --git a/bchain/coins/dogecoin/testdata/block_dump_testnet.158100 b/bchain/coins/dogecoin/testdata/block_dump_testnet.158100 new file mode 100644 index 0000000000..f625973ad6 --- /dev/null +++ b/bchain/coins/dogecoin/testdata/block_dump_testnet.158100 @@ -0,0 +1 @@ +02006200e93bd6199a1ebfe82f89f9da53dc97c7978c3ba7f06603e9866c94b69d746756a7614574898c979fe0ff516c3bfb3f598cc50f8622972aafa13d8acd028c9df0ee70e053b2cf051e0013bc5f0102000000010000000000000000000000000000000000000000000000000000000000000000ffffffff3d039469022cfabe6d6d06a27e6dd0e80eb6cf85f715521e481ad071f1e6d38157f32e68a6ea74ef15d20100000000000000010000000000000000000000000000000100901ec4bc1600001976a914dac6a0f3388893ba8bbc8427dbcabec174beb11488ac00000000 \ No newline at end of file diff --git a/bchain/coins/dogecoin/testdata/block_dump_testnet.3274642 b/bchain/coins/dogecoin/testdata/block_dump_testnet.3274642 new file mode 100644 index 0000000000..76e109d5a9 --- /dev/null +++ b/bchain/coins/dogecoin/testdata/block_dump_testnet.3274642 @@ -0,0 +1 @@ +04006200869dca31955ea74aa30247ad00005e0cbd369e005f01fd49bfb6a644fc08ca955f2f13db46ecd9bd845de66989b78ae6814f14a8260a084f35df137d8b2faf1237420861ffff0f1e000143690401000000010000000000000000000000000000000000000000000000000000000000000000ffffffff040392f731ffffffff0100b386e6e80000001976a9141551b84892e83048b77f814e9f0a84f42f775aad88ac000000000100000001c2cb22ad1ba98eec3ad4505eb0156fc07e9c56e8c8d414b3dfbfb345c1ea96a6010000006b483045022100a0f412c599482edc6408f2a0e6b0980962bdf2a2cd77e9423d3c82c1ce59de0802203c7465d11f8e91f354753b76abe13dd577f61663414f51eedb102e6529043c4e0121034ca2b96ac17517cb9267e13aaf41396f1a9e31395a18ed18a61e77dda6e11c59feffffff0200e40b54020000001976a914409657eb218741347b0801447c18c7ce489194e188ac00729ab8da0000001976a914d22a150165ef64631021c90706fd4dfd36567cdd88ac91f73100010000000156dc28fd53369a67adf07ab1d3dc5ecbde6c17ffa698a40954008338ac0b8900010000006a47304402206106636d75879f48376cab6946512ff9c451295680bc1a26c711be1c1f75dda0022059b669c1034021395cec946bd5856c43e9a2c23092e3ca1d67e7d71845ad8d2801210261074181a79ab56af65b4257486d0c71d2b3cab92c70b63d9c502a4df5295508feffffff0200497556a90000001976a914205ddbfd8710a4a452b8cf34e4d4710b6c05e66b88ac00e40b54020000001976a914409657eb218741347b0801447c18c7ce489194e188ac37f73100010000000109c798a31c52f748912318a3e0ccc445e0f9d1901f6ad46533f22f7df92d9a6a010000006b4830450221008c0873e5e4ec79e2a3939a090235e224fa334547af08ef1a3c26ff27a092afcf02204c408c77fb057ccfe78a2223393c6bd31f49d1cce6387000f3d03e64d6b333240121020715b41d4b010ff3fcb1d5190ef65c583fdd39274b7cb9bfa80bbcc8a0c5b4bcffffffff020065cd1d000000001976a914adb327a539d42a245920819c44e2da1638ecd6cd88ac00180d8f000000001976a91477686c9ba39bf1c557f7ea2462d6f2fd6cb1fa8888ac00000000 \ No newline at end of file diff --git a/bchain/coins/dogecoin/testdata/block_dump_testnet.99999 b/bchain/coins/dogecoin/testdata/block_dump_testnet.99999 new file mode 100644 index 0000000000..0f5f0a0561 --- /dev/null +++ b/bchain/coins/dogecoin/testdata/block_dump_testnet.99999 @@ -0,0 +1 @@ +02000000ef0109d480fd25f6d1e92d3557cd00e648ed6542d0a95798d114626633a78538da163af2d78b5e7d2f3462bfb1872eb3f54b3fbaa5a3afc9f974a49ca0f9338e8795895387880d1e0004563e0301000000010000000000000000000000000000000000000000000000000000000000000000ffffffff0b039f8601062f503253482fffffffff04510422b74c1500001976a9141baddeaea6dbdf929281a6c154f677858947e9ef88ac40c2e6301c3a00001976a914a49c481ffaada228e66d956db84dd59bc1a4431d88ac6fdbfb31cc000000434104ffd03de44a6e11b9917f3a29f9443283d9871c9d743ef30d5eddcd37094b64d1b3d8090496b53256786bf5c82932ec23c3b74d9f05a6f95a8b5529352656664bac00000000000000002a6a28676964ca2e3dc86c44ea5d375982a07b1c79c0316f648bbf9c6ae9e6657b14890000000000000000000000000100000001642932e2b3dad0daa6df49bfa968f49c08879b87fea4d13669f7564bbe153d94010000006b483045022100c893ee249241ae4ca3654ed9d2d2be8d3f12804fc7436767b208b37b22d1b5590220772e8b1568bb5196792047cc0666adae16e97524f67f4026fd27df339e0e6ec401210387690b37eecf988835a0a9c992943f499041b06cd46c51ff32a3c9b766cdbee2ffffffff0200e87648170000001976a91432fa715655094175dd3b9b83ed7aa731f0df6d8288ac009c39fb7c1803001976a914eb980913d844960b1c57cb923fc6f7d82b62887e88ac000000000100000001174e1eb90f529d9afd1cb003440c93d4669fc6c6338e740468f57bf8dfd9c863010000006a47304402201551a536e9ae05912d7651dab0dfa3a275d14ba6fe611421c4ddc35f762d3db80220221d2e1366907b9f68861a30b67b0c620ce80e178b8a8c86fe649d7e29db56d8012103055a80e0950eefc9792b152fb82643b954450123078d871d0be711616c64c96affffffff0200e87648170000001976a91432fa715655094175dd3b9b83ed7aa731f0df6d8288ac00b4c2b2651803001976a91454ac4121c7d53bdf450efe0e240fff3f21e5378e88ac00000000 \ No newline at end of file diff --git a/bchain/coins/ecash/ecashparser.go b/bchain/coins/ecash/ecashparser.go new file mode 100644 index 0000000000..2b4ef88184 --- /dev/null +++ b/bchain/coins/ecash/ecashparser.go @@ -0,0 +1,189 @@ +package ecash + +import ( + "fmt" + + "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" +) + +// AddressFormat type is used to specify different formats of address +type AddressFormat = uint8 + +const ( + // Legacy AddressFormat is the same as Bitcoin + Legacy AddressFormat = iota + // CashAddr AddressFormat is new eCash standard + CashAddr +) + +const ( + // MainNetPrefix is CashAddr prefix for mainnet + MainNetPrefix = "ecash:" + // TestNetPrefix is CashAddr prefix for testnet + TestNetPrefix = "ectest:" + // RegTestPrefix is CashAddr prefix for regtest + RegTestPrefix = "ecreg:" +) + +var ( + // MainNetParams are parser parameters for mainnet + MainNetParams chaincfg.Params + // TestNetParams are parser parameters for testnet + TestNetParams chaincfg.Params + // RegtestParams are parser parameters for regtest + RegtestParams chaincfg.Params +) + +func init() { + MainNetParams = chaincfg.MainNetParams + MainNetParams.Net = ecashutil.MainnetMagic + + TestNetParams = chaincfg.TestNet3Params + TestNetParams.Net = ecashutil.TestnetMagic + + RegtestParams = chaincfg.RegressionNetParams + RegtestParams.Net = ecashutil.Regtestmagic +} + +// ECashParser handle +type ECashParser struct { + *btc.BitcoinLikeParser + AddressFormat AddressFormat +} + +// NewECashParser returns new ECashParser instance +func NewECashParser(params *chaincfg.Params, c *btc.Configuration) (*ECashParser, error) { + var format AddressFormat + switch c.AddressFormat { + case "": + fallthrough + case "cashaddr": + format = CashAddr + case "legacy": + format = Legacy + default: + return nil, fmt.Errorf("Unknown address format: %s", c.AddressFormat) + } + p := &ECashParser{ + BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c), + AddressFormat: format, + } + p.OutputScriptToAddressesFunc = p.outputScriptToAddresses + p.AmountDecimalPoint = 2 + return p, nil +} + +// GetChainParams contains network parameters for the main eCash network, +// the regression test eCash network, the test eCash network and +// the simulation test eCash network, in this order +func GetChainParams(chain string) *chaincfg.Params { + if !chaincfg.IsRegistered(&MainNetParams) { + err := chaincfg.Register(&MainNetParams) + if err == nil { + err = chaincfg.Register(&TestNetParams) + } + if err == nil { + err = chaincfg.Register(&RegtestParams) + } + if err != nil { + panic(err) + } + } + switch chain { + case "test": + return &TestNetParams + case "regtest": + return &RegtestParams + default: + return &MainNetParams + } +} + +// GetAddrDescFromAddress returns internal address representation of given address +func (p *ECashParser) GetAddrDescFromAddress(address string) (bchain.AddressDescriptor, error) { + return p.addressToOutputScript(address) +} + +// addressToOutputScript converts address to ScriptPubKey +func (p *ECashParser) addressToOutputScript(address string) ([]byte, error) { + if isCashAddr(address) { + da, err := ecashutil.DecodeAddress(address, p.Params) + if err != nil { + return nil, err + } + script, err := ecashutil.PayToAddrScript(da) + if err != nil { + return nil, err + } + return script, nil + } + da, err := btcutil.DecodeAddress(address, p.Params) + if err != nil { + return nil, err + } + script, err := txscript.PayToAddrScript(da) + if err != nil { + return nil, err + } + return script, nil +} + +func isCashAddr(addr string) bool { + n := len(addr) + switch { + case n > len(MainNetPrefix) && addr[0:len(MainNetPrefix)] == MainNetPrefix: + return true + case n > len(TestNetPrefix) && addr[0:len(TestNetPrefix)] == TestNetPrefix: + return true + case n > len(RegTestPrefix) && addr[0:len(RegTestPrefix)] == RegTestPrefix: + return true + } + + return false +} + +// outputScriptToAddresses converts ScriptPubKey to bitcoin addresses +func (p *ECashParser) outputScriptToAddresses(script []byte) ([]string, bool, error) { + // convert possible P2PK script to P2PK, which ecashutil can process + var err error + script, err = txscript.ConvertP2PKtoP2PKH(p.Params.Base58CksumHasher, script) + if err != nil { + return nil, false, err + } + a, err := ecashutil.ExtractPkScriptAddrs(script, p.Params) + if err != nil { + // do not return unknown script type error as error + if err.Error() == "unknown script type" { + // try OP_RETURN script + or := p.TryParseOPReturn(script) + if or != "" { + return []string{or}, false, nil + } + return []string{}, false, nil + } + return nil, false, err + } + // EncodeAddress returns CashAddr address + addr := a.EncodeAddress() + if p.AddressFormat == Legacy { + da, err := address.NewFromString(addr) + if err != nil { + return nil, false, err + } + ca, err := da.Legacy() + if err != nil { + return nil, false, err + } + addr, err = ca.Encode() + if err != nil { + return nil, false, err + } + } + return []string{addr}, len(addr) > 0, nil +} diff --git a/bchain/coins/ecash/ecashparser_test.go b/bchain/coins/ecash/ecashparser_test.go new file mode 100644 index 0000000000..719ded6c2e --- /dev/null +++ b/bchain/coins/ecash/ecashparser_test.go @@ -0,0 +1,357 @@ +//go:build unittest + +package ecash + +import ( + "encoding/hex" + "math/big" + "os" + "reflect" + "testing" + + "github.com/martinboehm/btcutil/chaincfg" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" +) + +func TestMain(m *testing.M) { + c := m.Run() + chaincfg.ResetParams() + os.Exit(c) +} + +func Test_GetAddrDescFromAddress(t *testing.T) { + mainParserCashAddr, mainParserLegacy, testParserCashAddr, _ := setupParsers(t) + tests := []struct { + name string + parser *ECashParser + addresses []string + hex string + wantErr bool + }{ + { + name: "test-P2PKH-0", + parser: testParserCashAddr, + addresses: []string{"mnnAKPTSrWjgoi3uEYaQkHA1QEC5btFeBr"}, + hex: "76a9144fa927fd3bcf57d4e3c582c3d2eb2bd3df8df47c88ac", + wantErr: false, + }, + { + name: "test-P2PKH-1", + parser: testParserCashAddr, + addresses: []string{"ectest:qp86jfla8084048rckpv85ht90falr050s59h7rejp"}, + hex: "76a9144fa927fd3bcf57d4e3c582c3d2eb2bd3df8df47c88ac", + wantErr: false, + }, + { + name: "main-P2PKH-0", + parser: mainParserLegacy, + addresses: []string{"129HiRqekqPVucKy2M8zsqvafGgKypciPp"}, + hex: "76a9140c8967e6382c7a2ca64d8e850bfc99b7736e1a0d88ac", + wantErr: false, + }, + { + name: "main-P2PKH-1", + parser: mainParserCashAddr, + addresses: []string{"ecash:qqxgjelx8qk85t9xfk8g2zlunxmhxms6p5dtfghs9r"}, + hex: "76a9140c8967e6382c7a2ca64d8e850bfc99b7736e1a0d88ac", + wantErr: false, + }, + { + name: "main-P2SH-0", + parser: mainParserCashAddr, + addresses: []string{"3EBEFWPtDYWCNszQ7etoqtWmmygccayLiH"}, + hex: "a91488f772450c830a30eddfdc08a93d5f2ae1a30e1787", + wantErr: false, + }, + { + name: "main-P2SH-1", + parser: mainParserLegacy, + addresses: []string{"ecash:pzy0wuj9pjps5v8dmlwq32fatu4wrgcwzuyf5lgn3q"}, + hex: "a91488f772450c830a30eddfdc08a93d5f2ae1a30e1787", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.parser.GetAddrDescFromAddress(tt.addresses[0]) + if (err != nil) != tt.wantErr { + t.Errorf("%v", tt.addresses[0]) + t.Errorf("GetAddrDescFromAddress() error = %v, wantErr %v", err, tt.wantErr) + return + } + h := hex.EncodeToString(got) + if !reflect.DeepEqual(h, tt.hex) { + t.Errorf("GetAddrDescFromAddress() = %v, want %v", h, tt.hex) + } + }) + } +} + +func Test_GetAddressesFromAddrDesc(t *testing.T) { + mainParserCashAddr, mainParserLegacy, testParserCashAddr, testParserLegacy := setupParsers(t) + tests := []struct { + name string + parser *ECashParser + addresses []string + searchable bool + hex string + wantErr bool + }{ + { + name: "test-P2PKH-0", + parser: testParserLegacy, + addresses: []string{"mnnAKPTSrWjgoi3uEYaQkHA1QEC5btFeBr"}, + searchable: true, + hex: "76a9144fa927fd3bcf57d4e3c582c3d2eb2bd3df8df47c88ac", + + wantErr: false, + }, + { + name: "test-P2PKH-1", + parser: testParserCashAddr, + addresses: []string{"ectest:qp86jfla8084048rckpv85ht90falr050s59h7rejp"}, + searchable: true, + hex: "76a9144fa927fd3bcf57d4e3c582c3d2eb2bd3df8df47c88ac", + wantErr: false, + }, + { + name: "main-P2PKH-0", + parser: mainParserLegacy, + addresses: []string{"129HiRqekqPVucKy2M8zsqvafGgKypciPp"}, + searchable: true, + hex: "76a9140c8967e6382c7a2ca64d8e850bfc99b7736e1a0d88ac", + wantErr: false, + }, + { + name: "main-P2PKH-0", + parser: mainParserCashAddr, + addresses: []string{"ecash:qqxgjelx8qk85t9xfk8g2zlunxmhxms6p5dtfghs9r"}, + searchable: true, + hex: "76a9140c8967e6382c7a2ca64d8e850bfc99b7736e1a0d88ac", + wantErr: false, + }, + { + name: "main-P2SH-0", + parser: mainParserLegacy, + addresses: []string{"3EBEFWPtDYWCNszQ7etoqtWmmygccayLiH"}, + searchable: true, + hex: "a91488f772450c830a30eddfdc08a93d5f2ae1a30e1787", + wantErr: false, + }, + { + name: "main-P2SH-1", + parser: mainParserCashAddr, + addresses: []string{"ecash:pzy0wuj9pjps5v8dmlwq32fatu4wrgcwzuyf5lgn3q"}, + searchable: true, + hex: "a91488f772450c830a30eddfdc08a93d5f2ae1a30e1787", + wantErr: false, + }, + { + name: "main-P2PK", + parser: mainParserCashAddr, + addresses: []string{"ecash:qqr95pwp0w5jqnh9vcjl4qm4x45atr0er587pw66cr"}, + searchable: true, + hex: "2103db3c3977c5165058bf38c46f72d32f4e872112dbafc13083a948676165cd1603ac", + wantErr: false, + }, + { + name: "OP_RETURN ascii", + parser: mainParserCashAddr, + addresses: []string{"OP_RETURN (ahoj)"}, + searchable: false, + hex: "6a0461686f6a", + wantErr: false, + }, + { + name: "OP_RETURN hex", + parser: mainParserCashAddr, + addresses: []string{"OP_RETURN 2020f1686f6a20"}, + searchable: false, + hex: "6a072020f1686f6a20", + wantErr: false, + }, + { + name: "empty", + parser: mainParserCashAddr, + addresses: []string{}, + searchable: false, + hex: "", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, _ := hex.DecodeString(tt.hex) + got, got2, err := tt.parser.GetAddressesFromAddrDesc(b) + if (err != nil) != tt.wantErr { + t.Errorf("GetAddressesFromAddrDesc() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.addresses) { + t.Errorf("GetAddressesFromAddrDesc() = %v, want %v", got, tt.addresses) + } + if !reflect.DeepEqual(got2, tt.searchable) { + t.Errorf("GetAddressesFromAddrDesc() = %v, want %v", got2, tt.searchable) + } + }) + } +} + +var ( + testTx1, testTx2 bchain.Tx + testTxPacked1 = "0001e2408ba8d7af5401000000017f9a22c9cbf54bd902400df746f138f37bcf5b4d93eb755820e974ba43ed5f42040000006a4730440220037f4ed5427cde81d55b9b6a2fd08c8a25090c2c2fff3a75c1a57625ca8a7118022076c702fe55969fa08137f71afd4851c48e31082dd3c40c919c92cdbc826758d30121029f6da5623c9f9b68a9baf9c1bc7511df88fa34c6c2f71f7c62f2f03ff48dca80feffffff019c9700000000000017a9146144d57c8aff48492c9dfb914e120b20bad72d6f8773d00700" + testTxPacked2 = "0007c91a899ab7da6a010000000001019d64f0c72a0d206001decbffaa722eb1044534c74eee7a5df8318e42a4323ec10000000017160014550da1f5d25a9dae2eafd6902b4194c4c6500af6ffffffff02809698000000000017a914cd668d781ece600efa4b2404dc91fd26b8b8aed8870553d7360000000017a914246655bdbd54c7e477d0ea2375e86e0db2b8f80a8702473044022076aba4ad559616905fa51d4ddd357fc1fdb428d40cb388e042cdd1da4a1b7357022011916f90c712ead9a66d5f058252efd280439ad8956a967e95d437d246710bc9012102a80a5964c5612bb769ef73147b2cf3c149bc0fd4ecb02f8097629c94ab013ffd00000000" +) + +func setupParsers(t *testing.T) (mainParserCashAddr, mainParserLegacy, testParserCashAddr, testParserLegacy *ECashParser) { + parser1, err := NewECashParser(GetChainParams("main"), &btc.Configuration{AddressFormat: "cashaddr"}) + if err != nil { + t.Fatalf("NewECashParser() error = %v", err) + } + parser2, err := NewECashParser(GetChainParams("main"), &btc.Configuration{AddressFormat: "legacy"}) + if err != nil { + t.Fatalf("NewECashParser() error = %v", err) + } + parser3, err := NewECashParser(GetChainParams("test"), &btc.Configuration{AddressFormat: "cashaddr"}) + if err != nil { + t.Fatalf("NewECashParser() error = %v", err) + } + parser4, err := NewECashParser(GetChainParams("test"), &btc.Configuration{AddressFormat: "legacy"}) + if err != nil { + t.Fatalf("NewECashParser() error = %v", err) + } + return parser1, parser2, parser3, parser4 +} + +func init() { + + testTx1 = bchain.Tx{ + Hex: "01000000017f9a22c9cbf54bd902400df746f138f37bcf5b4d93eb755820e974ba43ed5f42040000006a4730440220037f4ed5427cde81d55b9b6a2fd08c8a25090c2c2fff3a75c1a57625ca8a7118022076c702fe55969fa08137f71afd4851c48e31082dd3c40c919c92cdbc826758d30121029f6da5623c9f9b68a9baf9c1bc7511df88fa34c6c2f71f7c62f2f03ff48dca80feffffff019c9700000000000017a9146144d57c8aff48492c9dfb914e120b20bad72d6f8773d00700", + Blocktime: 1519053802, + Txid: "056e3d82e5ffd0e915fb9b62797d76263508c34fe3e5dbed30dd3e943930f204", + LockTime: 512115, + Version: 1, + Vin: []bchain.Vin{ + { + ScriptSig: bchain.ScriptSig{ + Hex: "4730440220037f4ed5427cde81d55b9b6a2fd08c8a25090c2c2fff3a75c1a57625ca8a7118022076c702fe55969fa08137f71afd4851c48e31082dd3c40c919c92cdbc826758d30121029f6da5623c9f9b68a9baf9c1bc7511df88fa34c6c2f71f7c62f2f03ff48dca80", + }, + Txid: "425fed43ba74e9205875eb934d5bcf7bf338f146f70d4002d94bf5cbc9229a7f", + Vout: 4, + Sequence: 4294967294, + }, + }, + Vout: []bchain.Vout{ + { + ValueSat: *big.NewInt(38812), + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "a9146144d57c8aff48492c9dfb914e120b20bad72d6f87", + Addresses: []string{ + "ecash:pps5f4tu3tl5sjfvnhaeznsjpvst44eddu3yvcmmzj", + }, + }, + }, + }, + } + + testTx2 = bchain.Tx{ + Hex: "010000000001019d64f0c72a0d206001decbffaa722eb1044534c74eee7a5df8318e42a4323ec10000000017160014550da1f5d25a9dae2eafd6902b4194c4c6500af6ffffffff02809698000000000017a914cd668d781ece600efa4b2404dc91fd26b8b8aed8870553d7360000000017a914246655bdbd54c7e477d0ea2375e86e0db2b8f80a8702473044022076aba4ad559616905fa51d4ddd357fc1fdb428d40cb388e042cdd1da4a1b7357022011916f90c712ead9a66d5f058252efd280439ad8956a967e95d437d246710bc9012102a80a5964c5612bb769ef73147b2cf3c149bc0fd4ecb02f8097629c94ab013ffd00000000", + Blocktime: 1235678901, + Txid: "474e6795760ebe81cb4023dc227e5a0efe340e1771c89a0035276361ed733de7", + LockTime: 0, + Version: 1, + Vin: []bchain.Vin{ + { + ScriptSig: bchain.ScriptSig{ + Hex: "160014550da1f5d25a9dae2eafd6902b4194c4c6500af6", + }, + Txid: "c13e32a4428e31f85d7aee4ec7344504b12e72aaffcbde0160200d2ac7f0649d", + Vout: 0, + Sequence: 4294967295, + }, + }, + Vout: []bchain.Vout{ + { + ValueSat: *big.NewInt(10000000), + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "a914cd668d781ece600efa4b2404dc91fd26b8b8aed887", + Addresses: []string{ + "ectest:prxkdrtcrm8xqrh6fvjqfhy3l5nt3w9wmq7a4ujkec", + }, + }, + }, + { + ValueSat: *big.NewInt(920081157), + N: 1, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "a914246655bdbd54c7e477d0ea2375e86e0db2b8f80a87", + Addresses: []string{ + "ectest:pqjxv4dah42v0erh6r4zxa0gdcxm9w8cpg55qt4qtq", + }, + }, + }, + }, + } +} + +func Test_UnpackTx(t *testing.T) { + mainParser, _, testParser, _ := setupParsers(t) + + type args struct { + packedTx string + parser *ECashParser + } + tests := []struct { + name string + args args + want *bchain.Tx + want1 uint32 + wantErr bool + }{ + { + name: "ecash-1", + args: args{ + packedTx: testTxPacked1, + parser: mainParser, + }, + want: &testTx1, + want1: 123456, + wantErr: false, + }, + { + name: "testnet-1", + args: args{ + packedTx: testTxPacked2, + parser: testParser, + }, + want: &testTx2, + want1: 510234, + wantErr: false, + }, + } + for _, tt := range tests { + 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) + } + if got1 != tt.want1 { + t.Errorf("unpackTx() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} diff --git a/bchain/coins/ecash/ecashrpc.go b/bchain/coins/ecash/ecashrpc.go new file mode 100644 index 0000000000..a6be662ba7 --- /dev/null +++ b/bchain/coins/ecash/ecashrpc.go @@ -0,0 +1,207 @@ +package ecash + +import ( + "encoding/hex" + "encoding/json" + "math/big" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/pirk/ecashutil" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" +) + +// ECashRPC is an interface to JSON-RPC bitcoind service. +type ECashRPC struct { + *btc.BitcoinRPC +} + +// NewECashRPC returns new ECashRPC instance. +func NewECashRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { + b, err := btc.NewBitcoinRPC(config, pushHandler) + if err != nil { + return nil, err + } + + s := &ECashRPC{ + b.(*btc.BitcoinRPC), + } + s.ChainConfig.SupportsEstimateSmartFee = false + + return s, nil +} + +// Initialize initializes ECashRPC instance. +func (b *ECashRPC) Initialize() error { + ci, err := b.GetChainInfo() + if err != nil { + return err + } + chainName := ci.Chain + + params := GetChainParams(chainName) + + // always create parser + b.Parser, err = NewECashParser(params, b.ChainConfig) + + if err != nil { + return err + } + + // parameters for getInfo request + if params.Net == ecashutil.MainnetMagic { + b.Testnet = false + b.Network = "livenet" + } else { + b.Testnet = true + b.Network = "testnet" + } + + glog.Info("rpc: block chain ", params.Name) + + return nil +} + +// getblock + +type cmdGetBlock struct { + Method string `json:"method"` + Params struct { + BlockHash string `json:"blockhash"` + Verbose bool `json:"verbose"` + } `json:"params"` +} + +// estimatesmartfee + +type cmdEstimateSmartFee struct { + Method string `json:"method"` + Params struct { + Blocks int `json:"nblocks"` + } `json:"params"` +} + +// GetBlock returns block with given hash. +func (b *ECashRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) { + var err error + if hash == "" && height > 0 { + hash, err = b.GetBlockHash(height) + if err != nil { + return nil, err + } + } + header, err := b.GetBlockHeader(hash) + if err != nil { + return nil, err + } + data, err := b.GetBlockBytes(hash) + if err != nil { + return nil, err + } + block, err := b.Parser.ParseBlock(data) + if err != nil { + return nil, errors.Annotatef(err, "hash %v", hash) + } + // size is not returned by GetBlockHeader and would be overwritten + size := block.Size + block.BlockHeader = *header + block.Size = size + return block, nil +} + +// GetBlockRaw returns block with given hash as bytes. +func (b *ECashRPC) GetBlockRaw(hash string) (string, error) { + glog.V(1).Info("rpc: getblock (verbose=0) ", hash) + + res := btc.ResGetBlockRaw{} + req := cmdGetBlock{Method: "getblock"} + req.Params.BlockHash = hash + req.Params.Verbose = false + err := b.Call(&req, &res) + + if err != nil { + return "", errors.Annotatef(err, "hash %v", hash) + } + if res.Error != nil { + if isErrBlockNotFound(res.Error) { + return "", bchain.ErrBlockNotFound + } + return "", errors.Annotatef(res.Error, "hash %v", hash) + } + return res.Result, nil +} + +// GetBlockBytes returns block with given hash as bytes +func (b *ECashRPC) GetBlockBytes(hash string) ([]byte, error) { + block, err := b.GetBlockRaw(hash) + if err != nil { + return nil, err + } + return hex.DecodeString(block) +} + +// GetBlockInfo returns extended header (more info than in bchain.BlockHeader) with a list of txids +func (b *ECashRPC) GetBlockInfo(hash string) (*bchain.BlockInfo, error) { + glog.V(1).Info("rpc: getblock (verbosity=1) ", hash) + + res := btc.ResGetBlockInfo{} + req := cmdGetBlock{Method: "getblock"} + req.Params.BlockHash = hash + req.Params.Verbose = true + err := b.Call(&req, &res) + + if err != nil { + return nil, errors.Annotatef(err, "hash %v", hash) + } + if res.Error != nil { + if isErrBlockNotFound(res.Error) { + return nil, bchain.ErrBlockNotFound + } + return nil, errors.Annotatef(res.Error, "hash %v", hash) + } + return &res.Result, nil +} + +// GetBlockFull returns block with given hash. +func (b *ECashRPC) GetBlockFull(hash string) (*bchain.Block, error) { + return nil, errors.New("Not implemented") +} + +func isErrBlockNotFound(err *bchain.RPCError) bool { + return err.Message == "Block not found" || + err.Message == "Block height out of range" +} + +// EstimateFee returns fee estimation +func (b *ECashRPC) EstimateFee(blocks int) (big.Int, error) { + glog.V(1).Info("rpc: estimatefee ", blocks) + + res := btc.ResEstimateFee{} + req := struct { + Method string `json:"method"` + }{ + Method: "estimatefee", + } + + err := b.Call(&req, &res) + + var r big.Int + if err != nil { + return r, err + } + if res.Error != nil { + return r, res.Error + } + r, err = b.Parser.AmountToBigInt(res.Result) + if err != nil { + return r, err + } + return r, nil +} + +// EstimateSmartFee returns fee estimation +func (b *ECashRPC) EstimateSmartFee(blocks int, conservative bool) (big.Int, error) { + // EstimateSmartFee is not supported by ecash + return b.EstimateFee(blocks) +} 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/alternativefeeprovider_test.go b/bchain/coins/eth/alternativefeeprovider_test.go new file mode 100644 index 0000000000..aa17aa2921 --- /dev/null +++ b/bchain/coins/eth/alternativefeeprovider_test.go @@ -0,0 +1,50 @@ +package eth + +import "testing" + +// TestInitAlternativeFeeProviderFailFast verifies that when a coin config +// explicitly selects an EVM alternative fee provider whose required API-key env +// var is missing, initialization fails fast instead of silently reverting to +// default fee estimation. An unset provider stays a no-op. +func TestInitAlternativeFeeProviderFailFast(t *testing.T) { + const infuraParams = `{"url":"https://gas.api.infura.io/v3/${api_key}/networks/1/suggestedGasFees","periodSeconds":60}` + const oneInchParams = `{"url":"https://api.1inch.dev/gas-price/v1.5/1","periodSeconds":60}` + + tests := []struct { + name string + feeProvider string + params string + wantErr bool + }{ + {name: "infura without INFURA_API_KEY fails fast", feeProvider: "infura", params: infuraParams, wantErr: true}, + {name: "1inch without ONE_INCH_API_KEY fails fast", feeProvider: "1inch", params: oneInchParams, wantErr: true}, + {name: "no provider configured is a no-op", feeProvider: "", params: "", wantErr: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Ensure the required env vars are unset for this test. + t.Setenv("INFURA_API_KEY", "") + t.Setenv("ONE_INCH_API_KEY", "") + + b := &EthereumRPC{ChainConfig: &Configuration{ + AlternativeEstimateFee: tt.feeProvider, + AlternativeEstimateFeeParams: tt.params, + }} + + err := b.initAlternativeFeeProvider() + if tt.wantErr { + if err == nil { + t.Fatal("expected initialization to fail fast, got nil error") + } + if b.alternativeFeeProvider != nil { + t.Fatal("alternativeFeeProvider should be nil after a failed init") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/bchain/coins/eth/alternativesendtx.go b/bchain/coins/eth/alternativesendtx.go new file mode 100644 index 0000000000..fc9ed68d2b --- /dev/null +++ b/bchain/coins/eth/alternativesendtx.go @@ -0,0 +1,537 @@ +package eth + +import ( + "context" + "encoding/json" + "os" + "strings" + "sync" + "time" + + 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" + "github.com/trezor/blockbook/common" +) + +type storedTx struct { + tx *bchain.RpcTransaction + time uint32 +} + +const alternativeMempoolTxCheckPeriod = time.Minute + +// 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 + metrics *common.Metrics + removeTransactionFromMempool func(string) + watchMempoolTxsOnce sync.Once + stop chan struct{} + stopOnce sync.Once +} + +// NewAlternativeSendTxProvider creates a new alternative send tx provider if enabled +func NewAlternativeSendTxProvider(network string, rpcTimeout int, mempoolTxsTimeout time.Duration, metrics *common.Metrics) *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), + metrics: metrics, + stop: make(chan struct{}), + } + + 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 + if p.fetchMempoolTx { + p.watchMempoolTxsOnce.Do(func() { + go p.watchMempoolTxs() + }) + } +} + +// 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) { + tx, found, err := p.getTransactionFromProviders(txid) + if err != nil { + glog.Errorf("eth_getTransactionByHash from alternative providers returned error %v", err) + return txid, err + } else if !found { + glog.Errorf("eth_getTransactionByHash from alternative providers did not find txid %s", txid) + return txid, bchain.ErrTxNotFound + } + + p.mempoolTxsMux.Lock() + // remove potential RBF transactions - with equal from and nonce + var rbfTxid string + var rbfTime uint32 + for rbf, storedTx := range p.mempoolTxs { + if storedTx.tx.From == tx.From && storedTx.tx.AccountNonce == tx.AccountNonce { + rbfTxid = rbf + rbfTime = storedTx.time + 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) + // the replaced entry leaves the cache by fee-replacement rather than reconciliation; record the + // exit reason and its residence so the lifecycle metrics account for every way an entry leaves. + p.observeMempoolReconciliation("rbf_replaced") + p.observeMempoolTxResidence("rbf_replaced", rbfTime) + 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() + // the same staleness timeout the reconcile loop applies, just reached on the read path + // first; record it so the timeout counter and residence histogram do not undercount + // entries read after expiry but before the next reconcile cycle evicts them. + p.observeMempoolReconciliation("timeout") + p.observeMempoolTxResidence("timeout", storedTx.time) + return nil, false + } + return storedTx.tx, true + } + + return nil, false +} + +func (p *AlternativeSendTxProvider) watchMempoolTxs() { + ticker := time.NewTicker(alternativeMempoolTxCheckPeriod) + defer ticker.Stop() + for { + select { + case <-p.stop: + return + case <-ticker.C: + p.reconcileMempoolTxs() + } + } +} + +// shutdown stops the background mempool reconciliation goroutine. Safe to call on a +// nil receiver and more than once. +func (p *AlternativeSendTxProvider) shutdown() { + if p == nil || p.stop == nil { + return + } + p.stopOnce.Do(func() { close(p.stop) }) +} + +func (p *AlternativeSendTxProvider) reconcileMempoolTxs() { + type cachedTx struct { + txid string + tx storedTx + } + + p.mempoolTxsMux.Lock() + txs := make([]cachedTx, 0, len(p.mempoolTxs)) + for txid, tx := range p.mempoolTxs { + txs = append(txs, cachedTx{txid: txid, tx: tx}) + } + p.mempoolTxsMux.Unlock() + + // memoize confirmed-nonce lookups per sender so each sender is queried at most once per cycle + confirmedNonces := make(map[string]uint64) + confirmedNonceFailed := make(map[string]bool) + + for _, tx := range txs { + // a freshly submitted tx may transiently be unknown to a load-balanced provider node, + // give it one check period before reconciling + if time.Since(time.Unix(int64(tx.tx.time), 0)) < alternativeMempoolTxCheckPeriod { + p.observeMempoolReconciliation("skipped_fresh") + continue + } + timedOut := time.Unix(int64(tx.tx.time), 0).Before(time.Now().Add(-p.mempoolTxsTimeout)) + known, mined, err := p.providerKnowsTransaction(tx.txid) + if err != nil { + glog.Warningf("eth_getTransactionByHash from alternative provider failed for %s: %v", tx.txid, err) + if timedOut { + p.evictMempoolTx("timeout", tx.txid, tx.tx.time) + continue + } + p.observeMempoolReconciliation("provider_error") + continue + } + if mined { + p.evictMempoolTx("mined", tx.txid, tx.tx.time) + continue + } + + // The provider answered without error and the tx is not mined: it is either still reported as + // pending (known) or no longer surfaced by eth_getTransactionByHash (!known). If a different + // transaction has already consumed its nonce (e.g. a replacement submitted outside Blockbook), + // it can never be mined, so evict it deterministically instead of waiting for the timeout - + // regardless of whether the provider still surfaces it, because a spent nonce is a positive, + // irreversible on-chain fact. Only nonces strictly below the confirmed account nonce are + // treated as superseded; equal or higher nonces are still mineable (the next tx, or a gap + // waiting to be filled) and are left intact. + if p.transactionSupersededByNonce(tx.tx.tx, confirmedNonces, confirmedNonceFailed) { + p.evictMempoolTx("nonce_superseded", tx.txid, tx.tx.time) + continue + } + + if !known { + // A null/empty eth_getTransactionByHash is NOT authoritative proof the tx is gone: + // Blink-style private/MEV relays stop surfacing a still-pending, still-mineable tx via + // eth_getTransactionByHash while it stays broadcast. Evicting on a single empty probe + // deleted the tx from both sender and recipient ~1-2 minutes after send, even though it + // could still be mined. Defer eviction to the absolute cache timeout instead; mined and + // nonce_superseded above remain the only deterministic early evictions. + if timedOut { + p.evictMempoolTx("provider_missing", tx.txid, tx.tx.time) + continue + } + p.observeMempoolReconciliation("provider_missing_pending") + continue + } + + if timedOut { + p.evictMempoolTx("timeout", tx.txid, tx.tx.time) + continue + } + p.observeMempoolReconciliation("kept") + } + + p.mempoolTxsMux.Lock() + size := len(p.mempoolTxs) + p.mempoolTxsMux.Unlock() + p.setMempoolCacheSize(size) +} + +func (p *AlternativeSendTxProvider) observeMempoolReconciliation(action string) { + if p.metrics == nil || p.metrics.EthAlternativeMempoolEvents == nil { + return + } + p.metrics.EthAlternativeMempoolEvents.With(common.Labels{"action": action}).Inc() +} + +// evictMempoolTx records a terminal reconcile decision and removes the cache entry. It counts the +// decision and observes the entry's residence (how long it lived before this eviction reason fired), +// so the eviction rate and the per-reason lifetime distribution stay consistent. Decisions that keep +// an entry for a later cycle use observeMempoolReconciliation directly instead. +func (p *AlternativeSendTxProvider) evictMempoolTx(action, txid string, addedUnix uint32) { + p.observeMempoolReconciliation(action) + p.observeMempoolTxResidence(action, addedUnix) + p.removeMempoolTx(txid) +} + +// observeMempoolTxResidence records the age of a cache entry (seconds since it was broadcast) at the +// moment it is evicted, labeled by the deciding action. This makes the non-deterministic lifetime of +// an unconfirmed tx visible per eviction reason - e.g. provider_missing clustering near the timeout +// rather than at ~1-2 min would show a premature-eviction regression like the one #1573 describes. +func (p *AlternativeSendTxProvider) observeMempoolTxResidence(action string, addedUnix uint32) { + if p.metrics == nil || p.metrics.EthAlternativeMempoolTxResidence == nil { + return + } + residence := time.Since(time.Unix(int64(addedUnix), 0)).Seconds() + if residence < 0 { + residence = 0 + } + p.metrics.EthAlternativeMempoolTxResidence.With(common.Labels{"action": action}).Observe(residence) +} + +// setMempoolCacheSize records the current depth of the alternative send-tx mempool cache. +func (p *AlternativeSendTxProvider) setMempoolCacheSize(size int) { + if p.metrics == nil || p.metrics.EthAlternativeMempoolCacheSize == nil { + return + } + p.metrics.EthAlternativeMempoolCacheSize.Set(float64(size)) +} + +// transactionSupersededByNonce reports whether a different transaction has already consumed the +// cached transaction's nonce, making it permanently unmineable. Confirmed-nonce lookups are memoized +// per sender via resolved/failed so each sender is queried at most once per reconcile cycle. +func (p *AlternativeSendTxProvider) transactionSupersededByNonce(tx *bchain.RpcTransaction, resolved map[string]uint64, failed map[string]bool) bool { + if tx == nil || tx.From == "" || tx.AccountNonce == "" { + return false + } + txNonce, err := hexutil.DecodeUint64(tx.AccountNonce) + if err != nil { + glog.Warningf("alternative mempool: cannot parse nonce %q for tx %s: %v", tx.AccountNonce, tx.Hash, err) + return false + } + from := strings.ToLower(tx.From) + confirmed, ok := resolved[from] + if !ok { + if failed[from] { + return false + } + confirmed, err = p.getConfirmedNonce(tx.From) + if err != nil { + // keep the transaction on lookup failure; the timeout path remains the safety net + failed[from] = true + return false + } + resolved[from] = confirmed + } + return txNonce < confirmed +} + +// getConfirmedNonce returns the number of transactions mined from the address at the latest block, +// i.e. the lowest nonce not yet consumed on-chain. It queries every configured provider and returns +// the most conservative (lowest) value so a lagging or misbehaving provider cannot cause a still +// mineable transaction to be evicted. +// +// The "latest" tag carries the usual chain-tip caveat: if the nonce was consumed only in the tip +// block and that block is later reorged out, an eviction here may turn out premature. This is the +// same exposure as the mined-tx removal above and is bounded - eviction only drops Blockbook's cache +// entry, it cancels nothing on-chain, and a still-valid tx is re-indexed when it is actually mined. +func (p *AlternativeSendTxProvider) getConfirmedNonce(from string) (uint64, error) { + address := ethcommon.HexToAddress(from) + var lowest uint64 + var found bool + var firstErr error + for _, url := range p.urls { + result, err := p.callHttpStringResult(url, "eth_getTransactionCount", address, "latest") + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + nonce, err := hexutil.DecodeUint64(result) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if !found || nonce < lowest { + lowest = nonce + found = true + } + } + if !found { + if firstErr == nil { + firstErr = errors.New("no alternative provider returned a confirmed nonce") + } + return 0, firstErr + } + return lowest, nil +} + +func (p *AlternativeSendTxProvider) providerKnowsTransaction(txid string) (bool, bool, error) { + tx, found, err := p.getTransactionFromProviders(txid) + if err != nil || !found { + return found, false, err + } + return true, tx.BlockNumber != "", nil +} + +func (p *AlternativeSendTxProvider) getTransactionFromProviders(txid string) (*bchain.RpcTransaction, bool, error) { + hash := ethcommon.HexToHash(txid) + var firstErr error + for _, url := range p.urls { + raw, err := p.callHttpRawResult(url, "eth_getTransactionByHash", hash) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + + var tx bchain.RpcTransaction + if err := json.Unmarshal(raw, &tx); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if tx.Hash == "" { + continue + } + return &tx, true, nil + } + if firstErr != nil { + return nil, false, firstErr + } + return nil, false, nil +} + +func (p *AlternativeSendTxProvider) removeMempoolTx(txid string) { + if p.removeTransactionFromMempool != nil { + p.removeTransactionFromMempool(txid) + return + } + p.RemoveTransaction(txid) +} + +// 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 +} + +// getNonces returns the pending account nonce from the first configured alternative +// provider, plus the confirmed (latest) nonce when withConfirmed is set. When both are +// requested they are fetched in a single JSON-RPC batch round-trip; otherwise only the +// pending nonce is requested. The confirmed nonce is best-effort: a failed latest lookup +// yields confirmedOK=false (not an error) so the caller can omit it. An error is returned +// only when the required pending nonce cannot be obtained. +func (p *AlternativeSendTxProvider) getNonces(addr ethcommon.Address, withConfirmed bool) (uint64, uint64, bool, error) { + if len(p.urls) == 0 { + return 0, 0, false, errors.New("no alternative provider url configured") + } + if !withConfirmed { + pendingHex, err := p.callHttpStringResult(p.urls[0], "eth_getTransactionCount", addr, "pending") + if err != nil { + return 0, 0, false, err + } + pending, err := hexutil.DecodeUint64(pendingHex) + if err != nil { + return 0, 0, false, errors.Annotatef(err, "pending nonce %q", pendingHex) + } + return pending, 0, false, nil + } + ctx, cancel := context.WithTimeout(context.Background(), p.rpcTimeout) + defer cancel() + client, err := rpc.DialContext(ctx, p.urls[0]) + if err != nil { + return 0, 0, false, err + } + defer client.Close() + var pendingHex, confirmedHex string + batch := []rpc.BatchElem{ + {Method: "eth_getTransactionCount", Args: []interface{}{addr, "pending"}, Result: &pendingHex}, + {Method: "eth_getTransactionCount", Args: []interface{}{addr, "latest"}, Result: &confirmedHex}, + } + if err := client.BatchCallContext(ctx, batch); err != nil { + return 0, 0, false, err + } + if batch[0].Error != nil { + return 0, 0, false, batch[0].Error + } + pending, err := hexutil.DecodeUint64(pendingHex) + if err != nil { + return 0, 0, false, errors.Annotatef(err, "pending nonce %q", pendingHex) + } + confirmed, confirmedOK := decodeConfirmedNonce(addr, confirmedHex, batch[1].Error) + return pending, confirmed, confirmedOK, nil +} diff --git a/bchain/coins/eth/alternativesendtx_test.go b/bchain/coins/eth/alternativesendtx_test.go new file mode 100644 index 0000000000..5375ebf9a2 --- /dev/null +++ b/bchain/coins/eth/alternativesendtx_test.go @@ -0,0 +1,825 @@ +package eth + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/prometheus/client_golang/prometheus" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +const testAlternativeTxID = "0x1111111111111111111111111111111111111111111111111111111111111111" +const testAlternativeSecondTxID = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +// testAlternativeKnownTxResponse is an eth_getTransactionByHash result for a pending (not mined) +// transaction from the sender used in newTestAlternativeSendTxProvider. +const testAlternativeKnownTxResponse = `{"jsonrpc":"2.0","id":1,"result":{"hash":"` + testAlternativeTxID + `","from":"0x2222222222222222222222222222222222222222","nonce":"0x1","gas":"0x5208","value":"0x0","input":"0x","to":"0x3333333333333333333333333333333333333333"}}` + +func newAlternativeTxProviderTestServer(t *testing.T, response string) *httptest.Server { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // the handler runs in a different goroutine, t.Fatalf must not be called from here + if _, err := w.Write([]byte(response)); err != nil { + t.Errorf("Write() error = %v", err) + } + })) + t.Cleanup(server.Close) + + return server +} + +func newTestAlternativeSendTxProvider(url string, removed *string) *AlternativeSendTxProvider { + provider := &AlternativeSendTxProvider{ + urls: []string{url}, + fetchMempoolTx: true, + mempoolTxsTimeout: time.Hour, + rpcTimeout: time.Second, + mempoolTxs: map[string]storedTx{ + testAlternativeTxID: { + tx: &bchain.RpcTransaction{ + Hash: testAlternativeTxID, + From: "0x2222222222222222222222222222222222222222", + AccountNonce: "0x1", + }, + // older than the reconcile grace period so reconcileMempoolTxs checks it + time: uint32(time.Now().Add(-2 * alternativeMempoolTxCheckPeriod).Unix()), + }, + }, + } + provider.removeTransactionFromMempool = func(txid string) { + *removed = txid + provider.RemoveTransaction(txid) + } + return provider +} + +// assertReconcileOutcome checks whether the single cached test transaction was evicted (and reported +// through the removeTransactionFromMempool callback) or kept after a reconcile cycle. +func assertReconcileOutcome(t *testing.T, provider *AlternativeSendTxProvider, removed string, wantRemoved bool) { + t.Helper() + _, found := provider.mempoolTxs[testAlternativeTxID] + if wantRemoved { + if removed != testAlternativeTxID { + t.Fatalf("removed txid = %q, want %q", removed, testAlternativeTxID) + } + if found { + t.Fatal("transaction remained in alternative mempool cache, want removed") + } + return + } + if removed != "" { + t.Fatalf("removed txid = %q, want none", removed) + } + if !found { + t.Fatal("transaction was removed from alternative mempool cache, want kept") + } +} + +func TestAlternativeSendTxProviderReconcileLivenessOutcomes(t *testing.T) { + const minedTxResponse = `{"jsonrpc":"2.0","id":1,"result":{"hash":"` + testAlternativeTxID + `","from":"0x2222222222222222222222222222222222222222","nonce":"0x1","gas":"0x5208","value":"0x0","input":"0x","to":"0x3333333333333333333333333333333333333333","blockNumber":"0x1"}}` + tests := []struct { + name string + response string + wantRemoved bool + }{ + // A provider that returns empty is NOT authoritative proof the tx is gone (Blink-style + // private/MEV relays stop surfacing a still-pending tx via eth_getTransactionByHash). The tx + // is kept until the cache timeout - here mempoolTxsTimeout is time.Hour, so it stays. + {"empty provider result is kept until timeout", `{"jsonrpc":"2.0","id":1,"result":null}`, false}, + {"mined tx is removed", minedTxResponse, true}, + {"known pending tx is kept", testAlternativeKnownTxResponse, false}, + {"tx is kept on provider error", `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"temporary failure"}}`, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newAlternativeTxProviderTestServer(t, tt.response) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + + provider.reconcileMempoolTxs() + + assertReconcileOutcome(t, provider, removed, tt.wantRemoved) + }) + } +} + +func TestAlternativeSendTxProviderReconcileTimeoutEviction(t *testing.T) { + // A tx older than mempoolTxsTimeout must be evicted by the reconcile timeout "safety net", and - + // like every other eviction path - the eviction must go through removeMempoolTx (the + // removeTransactionFromMempool callback) so the tx is dropped from BOTH the main mempool and the + // alternative cache, not only the cache. assertReconcileOutcome checks the callback fired. + tests := []struct { + name string + serverURL func(t *testing.T) string + }{ + { + name: "provider error and timed out is removed", + serverURL: func(t *testing.T) string { + return newAlternativeTxProviderTestServer(t, `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"temporary failure"}}`).URL + }, + }, + { + // an empty provider result is kept while fresh (see ReconcileLivenessOutcomes) but the + // timeout safety net still evicts it once mempoolTxsTimeout has elapsed + name: "empty provider result and timed out is removed", + serverURL: func(t *testing.T) string { + return newAlternativeTxProviderTestServer(t, `{"jsonrpc":"2.0","id":1,"result":null}`).URL + }, + }, + { + name: "still pending, nonce not superseded and timed out is removed", + serverURL: func(t *testing.T) string { + return newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_getTransactionByHash": testAlternativeKnownTxResponse, + // confirmed nonce equals the tx nonce (0x1): not superseded, so only the timeout evicts it + "eth_getTransactionCount": nonceCountResponse("0x1"), + }).URL + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var removed string + provider := newTestAlternativeSendTxProvider(tt.serverURL(t), &removed) + // the cached tx is timestamped ~2 check periods ago; a tiny timeout makes it timed out + provider.mempoolTxsTimeout = time.Nanosecond + + provider.reconcileMempoolTxs() + + assertReconcileOutcome(t, provider, removed, true) + }) + } +} + +func TestAlternativeSendTxProviderReconcileSkipsFreshTransaction(t *testing.T) { + server := newAlternativeTxProviderTestServer(t, `{"jsonrpc":"2.0","id":1,"result":null}`) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + tx := provider.mempoolTxs[testAlternativeTxID] + tx.time = uint32(time.Now().Unix()) + provider.mempoolTxs[testAlternativeTxID] = tx + + provider.reconcileMempoolTxs() + + if removed != "" { + t.Fatalf("removed txid = %q, want none", removed) + } + if _, found := provider.mempoolTxs[testAlternativeTxID]; !found { + t.Fatal("freshly submitted transaction was removed from alternative mempool cache") + } +} + +func TestAlternativeSendTxProviderReconcileKeepsTransactionKnownByAnyProvider(t *testing.T) { + droppedServer := newAlternativeTxProviderTestServer(t, `{"jsonrpc":"2.0","id":1,"result":null}`) + knownServer := newAlternativeTxProviderTestServer(t, testAlternativeKnownTxResponse) + var removed string + provider := newTestAlternativeSendTxProvider(droppedServer.URL, &removed) + provider.urls = append(provider.urls, knownServer.URL) + + provider.reconcileMempoolTxs() + + if removed != "" { + t.Fatalf("removed txid = %q, want none", removed) + } + if _, found := provider.mempoolTxs[testAlternativeTxID]; !found { + t.Fatal("transaction known by a provider was removed from alternative mempool cache") + } +} + +func TestAlternativeSendTxProviderHandleMempoolTransactionFetchesFromAnyProvider(t *testing.T) { + droppedServer := newAlternativeTxProviderTestServer(t, `{"jsonrpc":"2.0","id":1,"result":null}`) + knownServer := newAlternativeTxProviderTestServer(t, testAlternativeKnownTxResponse) + var removed string + provider := newTestAlternativeSendTxProvider(droppedServer.URL, &removed) + provider.mempoolTxs = make(map[string]storedTx) + provider.urls = append(provider.urls, knownServer.URL) + + if _, err := provider.handleMempoolTransaction(testAlternativeTxID); err != nil { + t.Fatalf("handleMempoolTransaction() error = %v", err) + } + if _, found := provider.mempoolTxs[testAlternativeTxID]; !found { + t.Fatal("known transaction was not stored in alternative mempool cache") + } +} + +func TestAlternativeSendTxProviderHandleMempoolTransactionSkipsEmptyTransaction(t *testing.T) { + server := newAlternativeTxProviderTestServer(t, `{"jsonrpc":"2.0","id":1,"result":null}`) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + provider.mempoolTxs = make(map[string]storedTx) + + if _, err := provider.handleMempoolTransaction(testAlternativeTxID); err == nil { + t.Fatal("handleMempoolTransaction() error = nil, want ErrTxNotFound") + } + if _, found := provider.mempoolTxs[testAlternativeTxID]; found { + t.Fatal("empty transaction was stored in alternative mempool cache") + } +} + +func TestAlternativeSendTxProviderHandleMempoolTransactionSkipsTransactionWithoutHash(t *testing.T) { + server := newAlternativeTxProviderTestServer(t, `{"jsonrpc":"2.0","id":1,"result":{"from":"0x2222222222222222222222222222222222222222","nonce":"0x1","gas":"0x5208","value":"0x0","input":"0x","to":"0x3333333333333333333333333333333333333333"}}`) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + provider.mempoolTxs = make(map[string]storedTx) + + if _, err := provider.handleMempoolTransaction(testAlternativeTxID); err == nil { + t.Fatal("handleMempoolTransaction() error = nil, want ErrTxNotFound") + } + if _, found := provider.mempoolTxs[testAlternativeTxID]; found { + t.Fatal("transaction without hash was stored in alternative mempool cache") + } +} + +// methodAwareServer is a JSON-RPC test server that returns a different response per RPC method and +// records how many times each method was called. +type methodAwareServer struct { + *httptest.Server + mu sync.Mutex + calls map[string]int +} + +func (s *methodAwareServer) callCount(method string) int { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls[method] +} + +func newMethodAwareTxProviderTestServer(t *testing.T, responses map[string]string) *methodAwareServer { + t.Helper() + + s := &methodAwareServer{calls: make(map[string]int)} + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req struct { + Method string `json:"method"` + } + _ = json.Unmarshal(body, &req) + + s.mu.Lock() + s.calls[req.Method]++ + s.mu.Unlock() + + resp, ok := responses[req.Method] + if !ok { + resp = `{"jsonrpc":"2.0","id":1,"result":null}` + } + w.Header().Set("Content-Type", "application/json") + // the handler runs in a different goroutine, t.Fatalf must not be called from here + if _, err := w.Write([]byte(resp)); err != nil { + t.Errorf("Write() error = %v", err) + } + })) + t.Cleanup(s.Server.Close) + + return s +} + +func nonceCountResponse(hexNonce string) string { + return `{"jsonrpc":"2.0","id":1,"result":"` + hexNonce + `"}` +} + +func TestAlternativeSendTxProviderReconcileNonceOutcomes(t *testing.T) { + // the cached tx has nonce 0x1 and the provider still reports it as pending; only the confirmed + // account nonce returned by eth_getTransactionCount("latest") decides the outcome. + tests := []struct { + name string + txCountResponse string + wantRemoved bool + }{ + {"nonce below confirmed nonce is superseded and removed", nonceCountResponse("0x2"), true}, + {"nonce equal to confirmed nonce is kept (next mineable)", nonceCountResponse("0x1"), false}, + {"nonce above confirmed nonce is kept (gap, not evicted)", nonceCountResponse("0x0"), false}, + {"unparsable confirmed nonce keeps the tx", nonceCountResponse("0xZZ"), false}, + {"failed confirmed-nonce lookup keeps the tx", `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"temporary failure"}}`, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_getTransactionByHash": testAlternativeKnownTxResponse, + "eth_getTransactionCount": tt.txCountResponse, + }) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + + provider.reconcileMempoolTxs() + + assertReconcileOutcome(t, provider, removed, tt.wantRemoved) + }) + } +} + +func TestAlternativeSendTxProviderReconcileEvictsSupersededMissingTx(t *testing.T) { + // The provider no longer surfaces the tx via eth_getTransactionByHash (returns empty), so it is + // not evicted on the "missing" path alone. But the confirmed account nonce (0x2) is strictly + // above the cached tx nonce (0x1): the nonce is spent on-chain, so the tx can never be mined and + // is evicted deterministically even though it is well within the (1h) cache timeout. + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_getTransactionByHash": `{"jsonrpc":"2.0","id":1,"result":null}`, + "eth_getTransactionCount": nonceCountResponse("0x2"), + }) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + + provider.reconcileMempoolTxs() + + assertReconcileOutcome(t, provider, removed, true) +} + +func TestAlternativeSendTxProviderReconcileUsesLowestConfirmedNonce(t *testing.T) { + // one provider claims the nonce is consumed (0x2), another that it is still current (0x1). The + // conservative minimum (0x1) must win so a still-mineable tx is not evicted by a lagging node. + highServer := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_getTransactionByHash": testAlternativeKnownTxResponse, + "eth_getTransactionCount": nonceCountResponse("0x2"), + }) + lowServer := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_getTransactionByHash": testAlternativeKnownTxResponse, + "eth_getTransactionCount": nonceCountResponse("0x1"), + }) + var removed string + provider := newTestAlternativeSendTxProvider(highServer.URL, &removed) + provider.urls = append(provider.urls, lowServer.URL) + + provider.reconcileMempoolTxs() + + if removed != "" { + t.Fatalf("removed txid = %q, want none", removed) + } + if _, found := provider.mempoolTxs[testAlternativeTxID]; !found { + t.Fatal("transaction was evicted using a non-conservative confirmed nonce") + } +} + +func TestAlternativeSendTxProviderReconcileKeepsTransactionWithUnparsableNonce(t *testing.T) { + // a cached tx whose own nonce cannot be parsed must never be treated as superseded + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_getTransactionByHash": testAlternativeKnownTxResponse, + "eth_getTransactionCount": nonceCountResponse("0x2"), + }) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + tx := provider.mempoolTxs[testAlternativeTxID] + tx.tx.AccountNonce = "not-a-nonce" + provider.mempoolTxs[testAlternativeTxID] = tx + + provider.reconcileMempoolTxs() + + if removed != "" { + t.Fatalf("removed txid = %q, want none", removed) + } + if _, found := provider.mempoolTxs[testAlternativeTxID]; !found { + t.Fatal("transaction with an unparsable nonce was incorrectly evicted") + } +} + +func TestAlternativeSendTxProviderReconcileFailedNonceLookupIsPerSender(t *testing.T) { + // sender 0x2222 is checked here; a failed lookup for one sender must not suppress eviction for + // another. Single-provider servers cannot distinguish senders, so this test uses one sender and + // asserts the failed-memo does not leak into the resolved map. + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_getTransactionByHash": testAlternativeKnownTxResponse, + "eth_getTransactionCount": nonceCountResponse("0x2"), + }) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + + resolved := make(map[string]uint64) + failed := map[string]bool{"0x9999999999999999999999999999999999999999": true} + + tx := provider.mempoolTxs[testAlternativeTxID] + if !provider.transactionSupersededByNonce(tx.tx, resolved, failed) { + t.Fatal("a failed lookup for a different sender suppressed supersession of sender 0x2222") + } +} + +func TestAlternativeSendTxProviderReconcileMemoizesConfirmedNoncePerSender(t *testing.T) { + server := newMethodAwareTxProviderTestServer(t, map[string]string{ + "eth_getTransactionByHash": testAlternativeKnownTxResponse, + "eth_getTransactionCount": nonceCountResponse("0x2"), + }) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + // a second tx from the same sender must reuse the single confirmed-nonce lookup + provider.mempoolTxs[testAlternativeSecondTxID] = storedTx{ + tx: &bchain.RpcTransaction{ + Hash: testAlternativeSecondTxID, + From: "0x2222222222222222222222222222222222222222", + AccountNonce: "0x3", + }, + time: uint32(time.Now().Add(-2 * alternativeMempoolTxCheckPeriod).Unix()), + } + + provider.reconcileMempoolTxs() + + if got := server.callCount("eth_getTransactionCount"); got != 1 { + t.Fatalf("eth_getTransactionCount calls = %d, want 1 (memoized per sender)", got) + } + // nonce 0x1 < 0x2 is superseded and evicted; nonce 0x3 > 0x2 stays + if _, found := provider.mempoolTxs[testAlternativeTxID]; found { + t.Fatal("nonce-superseded transaction remained in alternative mempool cache") + } + if _, found := provider.mempoolTxs[testAlternativeSecondTxID]; !found { + t.Fatal("transaction ahead of the confirmed nonce was incorrectly evicted") + } +} + +// newReconcileTestMetrics builds a common.Metrics holding only the collectors reconcileMempoolTxs +// touches, left unregistered so each test owns fresh collectors and testutil can read them directly. +func newReconcileTestMetrics() *common.Metrics { + return &common.Metrics{ + EthAlternativeMempoolEvents: prometheus.NewCounterVec( + prometheus.CounterOpts{Name: "test_alt_mempool_events_total"}, []string{"action"}), + EthAlternativeMempoolTxResidence: prometheus.NewHistogramVec( + prometheus.HistogramOpts{Name: "test_alt_mempool_tx_residence_seconds", Buckets: []float64{30, 60, 120, 300, 600}}, []string{"action"}), + EthAlternativeMempoolCacheSize: prometheus.NewGauge( + prometheus.GaugeOpts{Name: "test_alt_mempool_cache_size"}), + } +} + +// The readers below register a collector in a throwaway registry and gather it, so a test can read +// metric values without pulling in the prometheus/testutil dependency (and its transitive modules). + +// gaugeValue reads the current value of a single gauge. +func gaugeValue(t *testing.T, g prometheus.Gauge) float64 { + t.Helper() + reg := prometheus.NewRegistry() + if err := reg.Register(g); err != nil { + t.Fatalf("register gauge: %v", err) + } + families, err := reg.Gather() + if err != nil { + t.Fatalf("gather gauge: %v", err) + } + for _, mf := range families { + for _, m := range mf.GetMetric() { + return m.GetGauge().GetValue() + } + } + return 0 +} + +// counterValue reads the value of the counter series carrying action=action. +func counterValue(t *testing.T, cv *prometheus.CounterVec, action string) float64 { + t.Helper() + reg := prometheus.NewRegistry() + if err := reg.Register(cv); err != nil { + t.Fatalf("register counter: %v", err) + } + families, err := reg.Gather() + if err != nil { + t.Fatalf("gather counter: %v", err) + } + for _, mf := range families { + for _, m := range mf.GetMetric() { + for _, lp := range m.GetLabel() { + if lp.GetName() == "action" && lp.GetValue() == action { + return m.GetCounter().GetValue() + } + } + } + } + return 0 +} + +// residenceSampleCount reports how many residence observations were recorded under action=action. +func residenceSampleCount(t *testing.T, h *prometheus.HistogramVec, action string) uint64 { + t.Helper() + reg := prometheus.NewRegistry() + if err := reg.Register(h); err != nil { + t.Fatalf("register residence histogram: %v", err) + } + families, err := reg.Gather() + if err != nil { + t.Fatalf("gather residence histogram: %v", err) + } + for _, mf := range families { + for _, m := range mf.GetMetric() { + for _, lp := range m.GetLabel() { + if lp.GetName() == "action" && lp.GetValue() == action { + return m.GetHistogram().GetSampleCount() + } + } + } + } + return 0 +} + +// TestAlternativeSendTxProviderReconcileObservesMetrics asserts the reconcile flow feeds the two +// metrics added to make it transparent: the per-action tx-lifetime histogram (observed only on +// eviction, under the same action label as the decision counter) and the cache-depth gauge. +func TestAlternativeSendTxProviderReconcileObservesMetrics(t *testing.T) { + const minedTxResponse = `{"jsonrpc":"2.0","id":1,"result":{"hash":"` + testAlternativeTxID + `","from":"0x2222222222222222222222222222222222222222","nonce":"0x1","gas":"0x5208","value":"0x0","input":"0x","to":"0x3333333333333333333333333333333333333333","blockNumber":"0x1"}}` + + t.Run("eviction records residence and zeroes the cache-depth gauge", func(t *testing.T) { + server := newAlternativeTxProviderTestServer(t, minedTxResponse) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + provider.metrics = newReconcileTestMetrics() + + provider.reconcileMempoolTxs() + + if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "mined"); got != 1 { + t.Errorf("mined reconciliation events = %v, want 1", got) + } + // the lifetime histogram records exactly one sample, under the same action label as the counter + if got := residenceSampleCount(t, provider.metrics.EthAlternativeMempoolTxResidence, "mined"); got != 1 { + t.Errorf("mined residence sample count = %d, want 1", got) + } + // the only cached tx was evicted, so the depth gauge settles at 0 + if got := gaugeValue(t, provider.metrics.EthAlternativeMempoolCacheSize); got != 0 { + t.Errorf("cache depth gauge = %v, want 0 after eviction", got) + } + }) + + t.Run("a kept tx records no residence and keeps the gauge at one", func(t *testing.T) { + server := newAlternativeTxProviderTestServer(t, testAlternativeKnownTxResponse) + var removed string + provider := newTestAlternativeSendTxProvider(server.URL, &removed) + provider.metrics = newReconcileTestMetrics() + + provider.reconcileMempoolTxs() + + if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "kept"); got != 1 { + t.Errorf("kept reconciliation events = %v, want 1", got) + } + // nothing was evicted, so no lifetime sample must be recorded for any terminal action + for _, action := range []string{"mined", "nonce_superseded", "provider_missing", "timeout"} { + if got := residenceSampleCount(t, provider.metrics.EthAlternativeMempoolTxResidence, action); got != 0 { + t.Errorf("residence sample count for %q = %d, want 0 when nothing is evicted", action, got) + } + } + if got := gaugeValue(t, provider.metrics.EthAlternativeMempoolCacheSize); got != 1 { + t.Errorf("cache depth gauge = %v, want 1 with one tx retained", got) + } + }) +} + +func TestAlternativeSendTxProviderGetTransactionTimeoutObservesMetrics(t *testing.T) { + // a cached entry past mempoolTxsTimeout, read before the reconcile loop reaches it, is evicted on + // the read path - and that eviction must be counted and have its residence observed just like the + // reconcile-loop timeout, otherwise the timeout series is undercounted. + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxsTimeout: time.Minute, + mempoolTxs: map[string]storedTx{ + testAlternativeTxID: { + tx: &bchain.RpcTransaction{Hash: testAlternativeTxID}, + time: uint32(time.Now().Add(-2 * time.Minute).Unix()), + }, + }, + metrics: newReconcileTestMetrics(), + } + + if tx, found := provider.GetTransaction(testAlternativeTxID); found || tx != nil { + t.Fatalf("timed-out tx: got (tx=%v found=%v), want (nil false)", tx, found) + } + if _, stillCached := provider.mempoolTxs[testAlternativeTxID]; stillCached { + t.Fatal("timed-out tx was not evicted from the cache") + } + if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "timeout"); got != 1 { + t.Errorf("timeout reconciliation events = %v, want 1", got) + } + if got := residenceSampleCount(t, provider.metrics.EthAlternativeMempoolTxResidence, "timeout"); got != 1 { + t.Errorf("timeout residence sample count = %d, want 1", got) + } +} + +func TestAlternativeSendTxProviderRBFReplacementObservesMetrics(t *testing.T) { + // handleMempoolTransaction replaces a cached entry sharing the incoming tx's sender+nonce. The + // replaced entry leaves the cache by fee-replacement, so that exit must be counted and its residence + // observed, not silently dropped. + server := newAlternativeTxProviderTestServer(t, testAlternativeKnownTxResponse) + provider := &AlternativeSendTxProvider{ + urls: []string{server.URL}, + fetchMempoolTx: true, + onlyAlternative: true, + rpcTimeout: time.Second, + mempoolTxsTimeout: time.Hour, + mempoolTxs: map[string]storedTx{ + // the older tx that the incoming testAlternativeTxID (same sender 0x2222, nonce 0x1) replaces + testAlternativeSecondTxID: { + tx: &bchain.RpcTransaction{ + Hash: testAlternativeSecondTxID, + From: "0x2222222222222222222222222222222222222222", + AccountNonce: "0x1", + }, + time: uint32(time.Now().Add(-3 * time.Minute).Unix()), + }, + }, + metrics: newReconcileTestMetrics(), + } + var removed string + provider.removeTransactionFromMempool = func(txid string) { + removed = txid + provider.RemoveTransaction(txid) + } + + if _, err := provider.handleMempoolTransaction(testAlternativeTxID); err != nil { + t.Fatalf("handleMempoolTransaction: %v", err) + } + + if removed != testAlternativeSecondTxID { + t.Fatalf("replaced txid = %q, want %q", removed, testAlternativeSecondTxID) + } + if got := counterValue(t, provider.metrics.EthAlternativeMempoolEvents, "rbf_replaced"); got != 1 { + t.Errorf("rbf_replaced events = %v, want 1", got) + } + if got := residenceSampleCount(t, provider.metrics.EthAlternativeMempoolTxResidence, "rbf_replaced"); got != 1 { + t.Errorf("rbf_replaced residence sample count = %d, want 1", got) + } +} + +func TestAlternativeSendTxProviderShutdownStopsWatchLoop(t *testing.T) { + provider := &AlternativeSendTxProvider{ + fetchMempoolTx: true, + mempoolTxs: make(map[string]storedTx), + stop: make(chan struct{}), + } + + done := make(chan struct{}) + go func() { + provider.watchMempoolTxs() + close(done) + }() + + provider.shutdown() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("watchMempoolTxs did not return after shutdown") + } + + // shutdown must be idempotent and must not panic when called again + provider.shutdown() +} + +func TestAlternativeSendTxProviderShutdownNilSafe(t *testing.T) { + // no alternative provider configured leaves a nil *AlternativeSendTxProvider; Shutdown must not panic + var provider *AlternativeSendTxProvider + provider.shutdown() +} + +// jsonRPCReq is the subset of a JSON-RPC request the nonce test server inspects. +type jsonRPCReq struct { + ID json.RawMessage `json:"id"` + Params []interface{} `json:"params"` +} + +// nonceRPCServer is a JSON-RPC test server for eth_getTransactionCount. It serves a per-block-tag +// hex result (or a per-tag JSON-RPC error) and supports both single and batched requests, so it can +// drive AlternativeSendTxProvider.getNonces over a real rpc.Client round-trip (the batched path uses +// BatchCallContext, which a plain method-keyed mock cannot exercise). It records how many times each +// block tag was queried so a test can assert the "latest" call is skipped when not requested. +type nonceRPCServer struct { + *httptest.Server + mu sync.Mutex + results map[string]string // tag -> hex result + errs map[string]bool // tag -> return a JSON-RPC error instead of a result + calls map[string]int // tag -> query count +} + +func (s *nonceRPCServer) callCount(tag string) int { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls[tag] +} + +func (s *nonceRPCServer) respond(req jsonRPCReq) string { + tag := "" + if len(req.Params) >= 2 { + tag, _ = req.Params[1].(string) + } + s.mu.Lock() + s.calls[tag]++ + s.mu.Unlock() + id := string(req.ID) + if id == "" { + id = "null" + } + if s.errs[tag] { + return `{"jsonrpc":"2.0","id":` + id + `,"error":{"code":-32000,"message":"temporary failure"}}` + } + return `{"jsonrpc":"2.0","id":` + id + `,"result":"` + s.results[tag] + `"}` +} + +func newNonceRPCServer(t *testing.T, results map[string]string, errs map[string]bool) *nonceRPCServer { + t.Helper() + + s := &nonceRPCServer{results: results, errs: errs, calls: make(map[string]int)} + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + // the handler runs in a different goroutine, t.Fatalf must not be called from here + var out string + if trimmed := bytes.TrimSpace(body); len(trimmed) > 0 && trimmed[0] == '[' { + var reqs []jsonRPCReq + if err := json.Unmarshal(body, &reqs); err != nil { + t.Errorf("Unmarshal batch request: %v", err) + return + } + parts := make([]string, 0, len(reqs)) + for _, req := range reqs { + parts = append(parts, s.respond(req)) + } + out = "[" + strings.Join(parts, ",") + "]" + } else { + var req jsonRPCReq + if err := json.Unmarshal(body, &req); err != nil { + t.Errorf("Unmarshal request: %v", err) + return + } + out = s.respond(req) + } + if _, err := w.Write([]byte(out)); err != nil { + t.Errorf("Write() error = %v", err) + } + })) + t.Cleanup(s.Server.Close) + + return s +} + +// TestAlternativeSendTxProviderGetNonces covers the alternative-provider nonce path that backs the +// confirmedNonce field for private/Blink relay coins. It mirrors the primary-RPC getNoncesRPC tests +// in nonce_test.go: pending-only when gated off, batched pending+confirmed when gated on, best-effort +// confirmed failure, fatal pending failure, and fatal batch transport failure. +func TestAlternativeSendTxProviderGetNonces(t *testing.T) { + addr := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + + t.Run("gated off fetches pending only", func(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x4", "latest": "0x2"}, nil) + provider := &AlternativeSendTxProvider{urls: []string{server.URL}, rpcTimeout: time.Second} + + pending, confirmed, confirmedOK, err := provider.getNonces(addr, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 4 || confirmed != 0 || confirmedOK { + t.Errorf("got (pending=%d confirmed=%d ok=%v), want (4 0 false)", pending, confirmed, confirmedOK) + } + if got := server.callCount("latest"); got != 0 { + t.Errorf("latest queried %d times, want 0 when confirmed nonce not requested", got) + } + if got := server.callCount("pending"); got != 1 { + t.Errorf("pending queried %d times, want 1", got) + } + }) + + t.Run("gated on batched success", func(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"pending": "0x4", "latest": "0x2"}, nil) + provider := &AlternativeSendTxProvider{urls: []string{server.URL}, rpcTimeout: time.Second} + + pending, confirmed, confirmedOK, err := provider.getNonces(addr, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 4 || confirmed != 2 || !confirmedOK { + t.Errorf("got (pending=%d confirmed=%d ok=%v), want (4 2 true)", pending, confirmed, confirmedOK) + } + }) + + t.Run("gated on confirmed failure is best-effort", func(t *testing.T) { + // the latest sub-call fails but pending succeeds: pending must still be returned with + // confirmedOK=false and NO error, so the whole address response survives + server := newNonceRPCServer(t, map[string]string{"pending": "0x4"}, map[string]bool{"latest": true}) + provider := &AlternativeSendTxProvider{urls: []string{server.URL}, rpcTimeout: time.Second} + + pending, confirmed, confirmedOK, err := provider.getNonces(addr, true) + if err != nil { + t.Fatalf("confirmed-nonce failure must not be fatal, got error: %v", err) + } + if pending != 4 || confirmed != 0 || confirmedOK { + t.Errorf("got (pending=%d confirmed=%d ok=%v), want (4 0 false) on best-effort failure", pending, confirmed, confirmedOK) + } + }) + + t.Run("gated on pending failure is fatal", func(t *testing.T) { + server := newNonceRPCServer(t, map[string]string{"latest": "0x2"}, map[string]bool{"pending": true}) + provider := &AlternativeSendTxProvider{urls: []string{server.URL}, rpcTimeout: time.Second} + + if _, _, _, err := provider.getNonces(addr, true); err == nil { + t.Fatal("expected fatal error when the required pending nonce cannot be obtained") + } + }) + + t.Run("batch transport failure is fatal", func(t *testing.T) { + // an unreachable provider makes the batch round-trip fail at transport level + provider := &AlternativeSendTxProvider{urls: []string{"http://127.0.0.1:1"}, rpcTimeout: time.Second} + + if _, _, _, err := provider.getNonces(addr, true); err == nil { + t.Fatal("expected fatal error on batch transport failure") + } + }) +} diff --git a/bchain/coins/eth/contract.go b/bchain/coins/eth/contract.go new file mode 100644 index 0000000000..7f53f84963 --- /dev/null +++ b/bchain/coins/eth/contract.go @@ -0,0 +1,624 @@ +package eth + +import ( + "context" + "math/big" + "strings" + + 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" +) + +const erc20TransferMethodSignature = "0xa9059cbb" // transfer(address,uint256) +const erc721TransferFromMethodSignature = "0x23b872dd" // transferFrom(address,address,uint256) +const erc721SafeTransferFromMethodSignature = "0x42842e0e" // safeTransferFrom(address,address,uint256) +const erc721SafeTransferFromWithDataMethodSignature = "0xb88d4fde" // safeTransferFrom(address,address,uint256,bytes) +const erc721TokenURIMethodSignature = "0xc87b56dd" // tokenURI(uint256) +const erc1155URIMethodSignature = "0x0e89341c" // uri(uint256) + +const tokenTransferEventSignature = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" +const tokenERC1155TransferSingleEventSignature = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62" +const tokenERC1155TransferBatchEventSignature = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb" + +const nameRegisteredEventSignature = "0xca6abbe9d7f11422cb6ca7629fbf6fe9efb1c621f71ce8f02b9f2a230097404f" + +const contractNameSignature = "0x06fdde03" +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 + if has0xPrefix(s) { + _, ok = t.SetString(s[2:], 16) + } else { + _, ok = t.SetString(s, 16) + } + if !ok { + return "", errors.New("Data is not a number") + } + a := ethcommon.BigToAddress(&t) + return a.String(), nil +} + +func processTransferEvent(l *bchain.RpcLog) (transfer *bchain.TokenTransfer, err error) { + defer func() { + if r := recover(); r != nil { + err = errors.Errorf("processTransferEvent recovered from panic %v", r) + } + }() + tl := len(l.Topics) + var standard bchain.TokenStandard + var value big.Int + if tl == 3 { + 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 { + standard = bchain.NonFungibleToken + _, ok := value.SetString(l.Topics[3], 0) + if !ok { + return nil, errors.New("ERC721 log Topics[3] is not a number") + } + } else { + return nil, nil + } + var from, to string + from, err = addressFromPaddedHex(l.Topics[1]) + if err != nil { + return nil, err + } + to, err = addressFromPaddedHex(l.Topics[2]) + if err != nil { + return nil, err + } + return &bchain.TokenTransfer{ + Standard: standard, + Contract: EIP55AddressFromAddress(l.Address), + From: EIP55AddressFromAddress(from), + To: EIP55AddressFromAddress(to), + Value: value, + }, nil +} + +func processERC1155TransferSingleEvent(l *bchain.RpcLog) (transfer *bchain.TokenTransfer, err error) { + defer func() { + if r := recover(); r != nil { + err = errors.Errorf("processERC1155TransferSingleEvent recovered from panic %v", r) + } + }() + tl := len(l.Topics) + if tl != 4 { + return nil, nil + } + var from, to string + from, err = addressFromPaddedHex(l.Topics[2]) + if err != nil { + return nil, err + } + to, err = addressFromPaddedHex(l.Topics[3]) + if err != nil { + return nil, err + } + var id, value big.Int + data := l.Data + if has0xPrefix(l.Data) { + data = data[2:] + } + _, ok := id.SetString(data[:64], 16) + if !ok { + return nil, errors.New("ERC1155 log Data id is not a number") + } + _, ok = value.SetString(data[64:128], 16) + if !ok { + return nil, errors.New("ERC1155 log Data value is not a number") + } + return &bchain.TokenTransfer{ + Standard: bchain.MultiToken, + Contract: EIP55AddressFromAddress(l.Address), + From: EIP55AddressFromAddress(from), + To: EIP55AddressFromAddress(to), + MultiTokenValues: []bchain.MultiTokenValue{{Id: id, Value: value}}, + }, nil +} + +func processERC1155TransferBatchEvent(l *bchain.RpcLog) (transfer *bchain.TokenTransfer, err error) { + defer func() { + if r := recover(); r != nil { + err = errors.Errorf("processERC1155TransferBatchEvent recovered from panic %v", r) + } + }() + tl := len(l.Topics) + if tl < 4 { + return nil, nil + } + var from, to string + from, err = addressFromPaddedHex(l.Topics[2]) + if err != nil { + return nil, err + } + to, err = addressFromPaddedHex(l.Topics[3]) + if err != nil { + return nil, err + } + data := l.Data + if has0xPrefix(l.Data) { + data = data[2:] + } + var b big.Int + _, ok := b.SetString(data[:64], 16) + if !ok || !b.IsInt64() { + return nil, errors.New("ERC1155 TransferBatch, not a number") + } + offsetIds := int(b.Int64()) * 2 + _, ok = b.SetString(data[64:128], 16) + if !ok || !b.IsInt64() { + return nil, errors.New("ERC1155 TransferBatch, not a number") + } + offsetValues := int(b.Int64()) * 2 + _, ok = b.SetString(data[offsetIds:offsetIds+64], 16) + if !ok || !b.IsInt64() { + return nil, errors.New("ERC1155 TransferBatch, not a number") + } + countIds := int(b.Int64()) + _, ok = b.SetString(data[offsetValues:offsetValues+64], 16) + if !ok || !b.IsInt64() { + return nil, errors.New("ERC1155 TransferBatch, not a number") + } + countValues := int(b.Int64()) + if countIds != countValues { + return nil, errors.New("ERC1155 TransferBatch, count values and ids does not match") + } + idValues := make([]bchain.MultiTokenValue, countValues) + for i := 0; i < countValues; i++ { + var id, value big.Int + o := offsetIds + 64 + 64*i + _, ok := id.SetString(data[o:o+64], 16) + if !ok { + return nil, errors.New("ERC1155 log Data id is not a number") + } + o = offsetValues + 64 + 64*i + _, ok = value.SetString(data[o:o+64], 16) + if !ok { + return nil, errors.New("ERC1155 log Data value is not a number") + } + idValues[i] = bchain.MultiTokenValue{Id: id, Value: value} + } + return &bchain.TokenTransfer{ + Standard: bchain.MultiToken, + Contract: EIP55AddressFromAddress(l.Address), + From: EIP55AddressFromAddress(from), + To: EIP55AddressFromAddress(to), + MultiTokenValues: idValues, + }, nil +} + +func contractGetTransfersFromLog(logs []*bchain.RpcLog) (bchain.TokenTransfers, error) { + var r bchain.TokenTransfers + var tt *bchain.TokenTransfer + var err error + for _, l := range logs { + tl := len(l.Topics) + if tl > 0 { + signature := l.Topics[0] + if signature == tokenTransferEventSignature { + tt, err = processTransferEvent(l) + } else if signature == tokenERC1155TransferSingleEventSignature { + tt, err = processERC1155TransferSingleEvent(l) + } else if signature == tokenERC1155TransferBatchEventSignature { + tt, err = processERC1155TransferBatchEvent(l) + } else { + continue + } + if err != nil { + return nil, err + } + if tt != nil { + r = append(r, tt) + } + } + } + return r, nil +} + +func contractGetTransfersFromTx(tx *bchain.RpcTransaction) (bchain.TokenTransfers, error) { + var r bchain.TokenTransfers + if len(tx.Payload) == 10+128 && strings.HasPrefix(tx.Payload, erc20TransferMethodSignature) { + to, err := addressFromPaddedHex(tx.Payload[10 : 10+64]) + if err != nil { + return nil, err + } + var t big.Int + _, ok := t.SetString(tx.Payload[10+64:], 16) + if !ok { + return nil, errors.New("Data is not a number") + } + r = append(r, &bchain.TokenTransfer{ + Standard: bchain.FungibleToken, + Contract: EIP55AddressFromAddress(tx.To), + From: EIP55AddressFromAddress(tx.From), + To: EIP55AddressFromAddress(to), + Value: t, + }) + } else if len(tx.Payload) >= 10+192 && + (strings.HasPrefix(tx.Payload, erc721TransferFromMethodSignature) || + strings.HasPrefix(tx.Payload, erc721SafeTransferFromMethodSignature) || + strings.HasPrefix(tx.Payload, erc721SafeTransferFromWithDataMethodSignature)) { + from, err := addressFromPaddedHex(tx.Payload[10 : 10+64]) + if err != nil { + return nil, err + } + to, err := addressFromPaddedHex(tx.Payload[10+64 : 10+128]) + if err != nil { + return nil, err + } + var t big.Int + _, ok := t.SetString(tx.Payload[10+128:10+192], 16) + if !ok { + return nil, errors.New("Data is not a number") + } + r = append(r, &bchain.TokenTransfer{ + Standard: bchain.NonFungibleToken, + Contract: EIP55AddressFromAddress(tx.To), + From: EIP55AddressFromAddress(from), + To: EIP55AddressFromAddress(to), + Value: t, + }) + } + return r, nil +} + +// 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 + 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 + 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 + // https://github.com/ethereum/go-ethereum/issues/21249#issuecomment-648647672 + // glog.Warning(errors.Annotatef(err, "Contract NameSignature %v", address)) + return nil, nil + // return nil, errors.Annotatef(err, "erc20NameSignature %v", address) + } + name := strings.TrimSpace(parseSimpleStringProperty(data)) + if name != "" { + 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)) + 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) + // } + contract = bchain.ContractInfo{ + Contract: address, + Name: name, + Symbol: symbol, + } + d := parseSimpleNumericProperty(data) + if d != nil { + contract.Decimals = int(uint8(d.Uint64())) + } else { + contract.Decimals = EtherAmountDecimalPoint + } + } else { + return nil, nil + } + return &contract, nil +} + +// GetContractInfo returns information about a contract +func (b *EthereumRPC) GetContractInfo(contractDesc bchain.AddressDescriptor) (*bchain.ContractInfo, error) { + address := EIP55Address(contractDesc) + return b.fetchContractInfo(address) +} + +// EthereumTypeGetErc20ContractBalance returns balance of ERC20 contract for given address +func (b *EthereumRPC) EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc bchain.AddressDescriptor) (*big.Int, error) { + 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 := 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 +} + +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 + if address == "0x06012c8cf97bead5deae237070f9587f8e7a266d" { + return "https://api.cryptokitties.co/kitties/" + tokenID.Text(10), nil + } + id := tokenID.Text(16) + if len(id) < 64 { + id = "0000000000000000000000000000000000000000000000000000000000000000"[len(id):] + id + } + // try ERC721 tokenURI method and ERC1155 uri method + for _, method := range []string{erc721TokenURIMethodSignature, erc1155URIMethodSignature} { + 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 + i := strings.LastIndex(uri, "ipfs://") + if i >= 0 { + uri = strings.Replace(uri[i:], "ipfs://", "https://ipfs.io/ipfs/", 1) + // some contracts return uri ipfs://ifps/abcdef instead of ipfs://abcdef + uri = strings.Replace(uri, "https://ipfs.io/ipfs/ipfs/", "https://ipfs.io/ipfs/", 1) + } + i = strings.LastIndex(uri, "https://") + // allow only https:// URIs + if i >= 0 { + uri = strings.ReplaceAll(uri[i:], "{id}", id) + return uri, nil + } + } + } + return "", nil +} 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 new file mode 100644 index 0000000000..ca70c85878 --- /dev/null +++ b/bchain/coins/eth/contract_test.go @@ -0,0 +1,291 @@ +//go:build unittest + +package eth + +import ( + "fmt" + "math/big" + "strings" + "testing" + + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +func Test_contractGetTransfersFromLog(t *testing.T) { + tests := []struct { + name string + args []*bchain.RpcLog + want bchain.TokenTransfers + wantErr bool + }{ + { + name: "ERC20 transfer 1", + args: []*bchain.RpcLog{ + { + Address: "0x76a45e8976499ab9ae223cc584019341d5a84e96", + Topics: []string{ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000002aacf811ac1a60081ea39f7783c0d26c500871a8", + "0x000000000000000000000000e9a5216ff992cfa01594d43501a56e12769eb9d2", + }, + Data: "0x0000000000000000000000000000000000000000000000000000000000000123", + }, + }, + want: bchain.TokenTransfers{ + { + Contract: "0x76a45e8976499ab9ae223cc584019341d5a84e96", + From: "0x2aacf811ac1a60081ea39f7783c0d26c500871a8", + To: "0xe9a5216ff992cfa01594d43501a56e12769eb9d2", + Value: *big.NewInt(0x123), + }, + }, + }, + { + name: "ERC20 transfer 2", + args: []*bchain.RpcLog{ + { // Transfer + Address: "0x0d0f936ee4c93e25944694d6c121de94d9760f11", + Topics: []string{ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000006f44cceb49b4a5812d54b6f494fc2febf25511ed", + "0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d", + }, + Data: "0x0000000000000000000000000000000000000000000000006a8313d60b1f606b", + }, + { // Transfer + Address: "0xc778417e063141139fce010982780140aa0cd5ab", + Topics: []string{ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d", + "0x0000000000000000000000006f44cceb49b4a5812d54b6f494fc2febf25511ed", + }, + Data: "0x000000000000000000000000000000000000000000000000000308fd0e798ac0", + }, + { // not Transfer + Address: "0x479cc461fecd078f766ecc58533d6f69580cf3ac", + Topics: []string{ + "0x0d0b9391970d9a25552f37d436d2aae2925e2bfe1b2a923754bada030c498cb3", + "0x0000000000000000000000006f44cceb49b4a5812d54b6f494fc2febf25511ed", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x5af266c0a89a07c1917deaa024414577e6c3c31c8907d079e13eb448c082594f", + }, + Data: "0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d0000000000000", + }, + { // not Transfer + Address: "0x0d0f936ee4c93e25944694d6c121de94d9760f11", + Topics: []string{ + "0x0d0b9391970d9a25552f37d436d2aae2925e2bfe1b2a923754bada030c498cb3", + "0x0000000000000000000000007b62eb7fe80350dc7ec945c0b73242cb9877fb1b", + "0xb0b69dad58df6032c3b266e19b1045b19c87acd2c06fb0c598090f44b8e263aa", + }, + Data: "0x0000000000000000000000004bda106325c335df99eab7fe363cac8a0ba2a24d000000000000000000000000c778417e063141139fce010982780140aa0cd5ab0000000000000000000000000d0f936ee4c93e25944694d6c121de94d9760f1100000000000000000000000000000000000000000000000000031855667df7a80000000000000000000000000000000000000000000000006a8313d60b1f800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + }, + }, + want: bchain.TokenTransfers{ + { + Contract: "0x0d0f936ee4c93e25944694d6c121de94d9760f11", + From: "0x6f44cceb49b4a5812d54b6f494fc2febf25511ed", + To: "0x4bda106325c335df99eab7fe363cac8a0ba2a24d", + Value: *big.NewInt(0x6a8313d60b1f606b), + }, + { + Contract: "0xc778417e063141139fce010982780140aa0cd5ab", + From: "0x4bda106325c335df99eab7fe363cac8a0ba2a24d", + To: "0x6f44cceb49b4a5812d54b6f494fc2febf25511ed", + Value: *big.NewInt(0x308fd0e798ac0), + }, + }, + }, + { + name: "ERC721 transfer 1", + args: []*bchain.RpcLog{ + { // Approval + Address: "0x5689b918D34C038901870105A6C7fc24744D31eB", + Topics: []string{ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x0000000000000000000000000a206d4d5ff79cb5069def7fe3598421cff09391", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000001396", + }, + Data: "0x", + }, + { // Transfer + Address: "0x5689b918D34C038901870105A6C7fc24744D31eB", + Topics: []string{ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000a206d4d5ff79cb5069def7fe3598421cff09391", + "0x0000000000000000000000006a016d7eec560549ffa0fbdb7f15c2b27302087f", + "0x0000000000000000000000000000000000000000000000000000000000001396", + }, + Data: "0x", + }, + { // OrdersMatched + Address: "0x7Be8076f4EA4A4AD08075C2508e481d6C946D12b", + Topics: []string{ + "0xc4109843e0b7d514e4c093114b863f8e7d8d9a458c372cd51bfe526b588006c9", + "0x0000000000000000000000000a206d4d5ff79cb5069def7fe3598421cff09391", + "0x0000000000000000000000006a016d7eec560549ffa0fbdb7f15c2b27302087f", + "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + Data: "0x000000000000000000000000000000000000000000000000000000000000000069d3f0cc25f121f2aa96215f51ec4b4f1966f2d2ffbd3d8d8a45ad27b1c90323000000000000000000000000000000000000000000000000008e1bc9bf040000", + }, + }, + want: bchain.TokenTransfers{ + { + Standard: bchain.NonFungibleToken, + Contract: "0x5689b918D34C038901870105A6C7fc24744D31eB", + From: "0x0a206d4d5ff79cb5069def7fe3598421cff09391", + To: "0x6a016d7eec560549ffa0fbdb7f15c2b27302087f", + Value: *big.NewInt(0x1396), + }, + }, + }, + { + name: "ERC1155 TransferSingle", + args: []*bchain.RpcLog{ + { // Transfer + Address: "0x6Fd712E3A5B556654044608F9129040A4839E36c", + Topics: []string{ + "0x5f9832c7244497a64c11c4a4f7597934bdf02b0361c54ad8e90091c2ce1f9e3c", + }, + Data: "0x000000000000000000000000a3950b823cb063dd9afc0d27f35008b805b3ed530000000000000000000000004392faf3bb96b5694ecc6ef64726f61cdd4bb0ec000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000009600000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001", + }, + { // TransferSingle + Address: "0x6Fd712E3A5B556654044608F9129040A4839E36c", + Topics: []string{ + "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62", + "0x0000000000000000000000009248a6048a58db9f0212dc7cd85ee8741128be72", + "0x000000000000000000000000a3950b823cb063dd9afc0d27f35008b805b3ed53", + "0x0000000000000000000000004392faf3bb96b5694ecc6ef64726f61cdd4bb0ec", + }, + Data: "0x00000000000000000000000000000000000000000000000000000000000000960000000000000000000000000000000000000000000000000000000000000011", + }, + { // unknown + Address: "0x9248A6048a58db9f0212dC7CD85eE8741128be72", + Topics: []string{ + "0x0b7bef9468bee71526deef3cbbded0ec1a0aa3d5a3e81eaffb0e758552b33199", + }, + Data: "0x0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000a3950b823cb063dd9afc0d27f35008b805b3ed530000000000000000000000004392faf3bb96b5694ecc6ef64726f61cdd4bb0ec0000000000000000000000000000000000000000000000000000000000000001", + }, + }, + want: bchain.TokenTransfers{ + { + Standard: bchain.MultiToken, + Contract: "0x6Fd712E3A5B556654044608F9129040A4839E36c", + From: "0xa3950b823cb063dd9afc0d27f35008b805b3ed53", + To: "0x4392faf3bb96b5694ecc6ef64726f61cdd4bb0ec", + MultiTokenValues: []bchain.MultiTokenValue{{Id: *big.NewInt(150), Value: *big.NewInt(0x11)}}, + }, + }, + }, + { + name: "ERC1155 TransferBatch", + args: []*bchain.RpcLog{ + { // TransferBatch + Address: "0x6c42C26a081c2F509F8bb68fb7Ac3062311cCfB7", + Topics: []string{ + "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb", + "0x0000000000000000000000005dc6288b35e0807a3d6feb89b3a2ff4ab773168e", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000005dc6288b35e0807a3d6feb89b3a2ff4ab773168e", + }, + Data: "0x000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000006f0000000000000000000000000000000000000000000000000000000000000076a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + }, + }, + want: bchain.TokenTransfers{ + { + Standard: bchain.MultiToken, + Contract: "0x6c42c26a081c2f509f8bb68fb7ac3062311ccfb7", + From: "0x0000000000000000000000000000000000000000", + To: "0x5dc6288b35e0807a3d6feb89b3a2ff4ab773168e", + MultiTokenValues: []bchain.MultiTokenValue{ + {Id: *big.NewInt(1776), Value: *big.NewInt(1)}, + {Id: *big.NewInt(1898), Value: *big.NewInt(10)}, + }, + }, + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := contractGetTransfersFromLog(tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("contractGetTransfersFromLog error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != len(tt.want) { + t.Errorf("contractGetTransfersFromLog len not same, %+v, want %+v", got, tt.want) + } + for i := range got { + // the addresses could have different case + if strings.ToLower(fmt.Sprint(got[i])) != strings.ToLower(fmt.Sprint(tt.want[i])) { + t.Errorf("contractGetTransfersFromLog %d = %+v, want %+v", i, got[i], tt.want[i]) + } + + } + }) + } +} + +func Test_contractGetTransfersFromTx(t *testing.T) { + p := NewEthereumParser(1, false) + b1 := dbtestdata.GetTestEthereumTypeBlock1(p) + b2 := dbtestdata.GetTestEthereumTypeBlock2(p) + bn, _ := new(big.Int).SetString("21e19e0c9bab2400000", 16) + tests := []struct { + name string + args *bchain.RpcTransaction + want bchain.TokenTransfers + }{ + { + name: "no contract transfer", + args: (b1.Txs[0].CoinSpecificData.(bchain.EthereumSpecificData)).Tx, + want: bchain.TokenTransfers{}, + }, + { + name: "ERC20 transfer", + args: (b1.Txs[1].CoinSpecificData.(bchain.EthereumSpecificData)).Tx, + want: bchain.TokenTransfers{ + { + Standard: bchain.FungibleToken, + Contract: "0x4af4114f73d1c1c903ac9e0361b379d1291808a2", + From: "0x20cd153de35d469ba46127a0c8f18626b59a256a", + To: "0x555ee11fbddc0e49a9bab358a8941ad95ffdb48f", + Value: *bn, + }, + }, + }, + { + name: "ERC721 transferFrom", + args: (b2.Txs[2].CoinSpecificData.(bchain.EthereumSpecificData)).Tx, + want: bchain.TokenTransfers{ + { + Standard: bchain.NonFungibleToken, + Contract: "0xcda9fc258358ecaa88845f19af595e908bb7efe9", + From: "0x837e3f699d85a4b0b99894567e9233dfb1dcb081", + To: "0x7b62eb7fe80350dc7ec945c0b73242cb9877fb1b", + Value: *big.NewInt(1), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := contractGetTransfersFromTx(tt.args) + if err != nil { + t.Errorf("contractGetTransfersFromTx error = %v", err) + return + } + if len(got) != len(tt.want) { + t.Errorf("contractGetTransfersFromTx len not same, %+v, want %+v", got, tt.want) + } + for i := range got { + // the addresses could have different case + if strings.ToLower(fmt.Sprint(got[i])) != strings.ToLower(fmt.Sprint(tt.want[i])) { + t.Errorf("contractGetTransfersFromTx %d = %+v, want %+v", i, got[i], tt.want[i]) + } + + } + }) + } +} diff --git a/bchain/coins/eth/dataparser.go b/bchain/coins/eth/dataparser.go new file mode 100644 index 0000000000..b3f641622b --- /dev/null +++ b/bchain/coins/eth/dataparser.go @@ -0,0 +1,317 @@ +package eth + +import ( + "bytes" + "encoding/hex" + "math/big" + "runtime/debug" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/golang/glog" + "github.com/trezor/blockbook/bchain" +) + +func parseSimpleNumericProperty(data string) *big.Int { + if has0xPrefix(data) { + data = data[2:] + } + if len(data) > 64 { + data = data[:64] + } + if len(data) == 64 { + var n big.Int + _, ok := n.SetString(data, 16) + if ok { + return &n + } + } + return nil +} + +func parseSimpleStringProperty(data string) string { + if has0xPrefix(data) { + data = data[2:] + } + if len(data) > 128 { + n := parseSimpleNumericProperty(data[64:128]) + if n != nil { + l := n.Int64() + if l > 0 && int(l) <= ((len(data)-128)>>1) { + b, err := hex.DecodeString(data[128 : 128+2*l]) + if err == nil { + return string(b) + } + } + } + } + // allow string properties as UTF-8 data + b, err := hex.DecodeString(data) + if err == nil { + i := min(bytes.Index(b, []byte{0}), 32) + if i > 0 { + b = b[:i] + } + if utf8.Valid(b) { + return string(b) + } + } + return "" +} + +func decamel(s string) string { + var b bytes.Buffer + splittable := false + for i, v := range s { + if i == 0 { + b.WriteRune(unicode.ToUpper(v)) + } else { + if splittable && unicode.IsUpper(v) { + b.WriteByte(' ') + } + b.WriteRune(v) + // special handling of ETH to be able to convert "addETHToContract" to "Add ETH To Contract" + splittable = unicode.IsLower(v) || unicode.IsNumber(v) || (i >= 2 && s[i-2:i+1] == "ETH") + } + } + return b.String() +} + +func GetSignatureFromData(data string) uint32 { + if has0xPrefix(data) { + data = data[2:] + } + if len(data) < 8 { + return 0 + } + sig, err := strconv.ParseUint(data[:8], 16, 32) + if err != nil { + return 0 + } + return uint32(sig) +} + +const ErrorTy byte = 255 + +func processParam(data string, index int, dataOffset int, t *abi.Type, processed []bool) ([]string, int, bool) { + var retval []string + d := index << 6 + if d+64 > len(data) { + return nil, 0, false + } + block := data[d : d+64] + switch t.T { + // static types + case abi.IntTy, abi.UintTy, abi.BoolTy: + var n big.Int + _, ok := n.SetString(block, 16) + if !ok { + return nil, 0, false + } + if t.T == abi.BoolTy { + if n.Int64() != 0 { + retval = []string{"true"} + } else { + retval = []string{"false"} + } + } else { + retval = []string{n.String()} + } + processed[index] = true + index++ + case abi.AddressTy: + b, err := hex.DecodeString(block[24:]) + if err != nil { + return nil, 0, false + } + retval = []string{EIP55Address(b)} + processed[index] = true + index++ + case abi.FixedBytesTy: + retval = []string{"0x" + block[:t.Size<<1]} + processed[index] = true + index++ + case abi.ArrayTy: + for i := 0; i < t.Size; i++ { + var r []string + var ok bool + r, index, ok = processParam(data, index, dataOffset, t.Elem, processed) + if !ok { + return nil, 0, false + } + retval = append(retval, r...) + } + // dynamic types + case abi.StringTy, abi.BytesTy, abi.SliceTy: + // get offset of dynamic type + offset, err := strconv.ParseInt(block, 16, 64) + if err != nil { + return nil, 0, false + } + processed[index] = true + index++ + offset <<= 1 + d = int(offset) + dataOffset + dynIndex := d >> 6 + if d+64 > len(data) || d < 0 { + return nil, 0, false + } + // get element count of dynamic type + c, err := strconv.ParseInt(data[d:d+64], 16, 64) + if err != nil { + return nil, 0, false + } + count := int(c) + processed[dynIndex] = true + dynIndex++ + if t.T == abi.StringTy || t.T == abi.BytesTy { + d += 64 + de := d + (count << 1) + if de > len(data) || de < 0 { + return nil, 0, false + } + if count == 0 { + retval = []string{""} + } else { + block = data[d:de] + if t.T == abi.StringTy { + b, err := hex.DecodeString(block) + if err != nil { + return nil, 0, false + } + retval = []string{string(b)} + } else { + retval = []string{"0x" + block} + } + count = ((count - 1) >> 5) + 1 + for i := 0; i < count; i++ { + processed[dynIndex] = true + dynIndex++ + } + } + } else { + newOffset := dataOffset + dynIndex<<6 + for i := 0; i < count; i++ { + var r []string + var ok bool + r, dynIndex, ok = processParam(data, dynIndex, newOffset, t.Elem, processed) + if !ok { + return nil, 0, false + } + retval = append(retval, r...) + } + } + // types not processed + case abi.HashTy, abi.FixedPointTy, abi.FunctionTy, abi.TupleTy: + fallthrough + default: + return nil, 0, false + } + return retval, index, true +} + +func tryParseParams(data string, params []string, parsedParams []abi.Type) []bchain.EthereumParsedInputParam { + processed := make([]bool, len(data)/64) + parsed := make([]bchain.EthereumParsedInputParam, len(params)) + index := 0 + var values []string + var ok bool + for i := range params { + t := &parsedParams[i] + values, index, ok = processParam(data, index, 0, t, processed) + if !ok { + return nil + } + parsed[i] = bchain.EthereumParsedInputParam{Type: params[i], Values: values} + } + // all data must be processed, otherwise wrong signature + for _, p := range processed { + if !p { + return nil + } + } + return parsed +} + +// 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 (p *EthereumParser) ParseInputData(signatures *[]bchain.FourByteSignature, data string) *bchain.EthereumParsedInputData { + if len(data) <= 2 { // data is empty or 0x + return &bchain.EthereumParsedInputData{Name: "Transfer"} + } + if len(data) < 10 { + return nil + } + parsed := bchain.EthereumParsedInputData{ + MethodId: data[:10], + } + defer func() { + if r := recover(); r != nil { + glog.Error("ParseInputData recovered from panic: ", r, ", ", data, ",signatures ", signatures) + debug.PrintStack() + } + }() + if signatures != nil { + data = data[10:] + for i := range *signatures { + s := &(*signatures)[i] + // if not yet done, set DecamelName and Function and parse parameter types from string to abi.Type + // the signatures are stored in cache + if s.DecamelName == "" { + s.DecamelName = decamel(s.Name) + s.Function = s.Name + "(" + strings.Join(s.Parameters, ", ") + ")" + s.ParsedParameters = make([]abi.Type, len(s.Parameters)) + for j := range s.Parameters { + var t abi.Type + if len(s.Parameters[j]) > 0 && s.Parameters[j][0] == '(' { + // Tuple type is not supported for now + t = abi.Type{T: abi.TupleTy} + } else { + var err error + t, err = abi.NewType(s.Parameters[j], "", nil) + if err != nil { + t = abi.Type{T: ErrorTy} + } + } + s.ParsedParameters[j] = t + } + } + parsedParams := tryParseParams(data, s.Parameters, s.ParsedParameters) + if parsedParams != nil { + parsed.Name = s.DecamelName + parsed.Function = s.Function + parsed.Params = parsedParams + break + } + } + } + return &parsed +} + +// getEnsRecord processes transaction log entry and tries to parse ENS record from it +func getEnsRecord(l *rpcLogWithTxHash) *bchain.AddressAliasRecord { + if len(l.Topics) == 3 && l.Topics[0] == nameRegisteredEventSignature && len(l.Data) >= 322 { + address, err := addressFromPaddedHex(l.Topics[2]) + if err != nil { + return nil + } + c, err := strconv.ParseInt(l.Data[194:194+64], 16, 64) + if err != nil { + return nil + } + de := 194 + 64 + (int(c) << 1) + if de > len(l.Data) || de < 0 { + return nil + } + b, err := hex.DecodeString(l.Data[194+64 : de]) + if err != nil { + return nil + } + return &bchain.AddressAliasRecord{Address: address, Name: string(b)} + } + return nil +} diff --git a/bchain/coins/eth/dataparser_test.go b/bchain/coins/eth/dataparser_test.go new file mode 100644 index 0000000000..34cbda8ea1 --- /dev/null +++ b/bchain/coins/eth/dataparser_test.go @@ -0,0 +1,507 @@ +//go:build unittest + +package eth + +import ( + "reflect" + "testing" + + "github.com/trezor/blockbook/bchain" +) + +func Test_parseSimpleStringProperty(t *testing.T) { + tests := []struct { + name string + args string + want string + }{ + { + name: "1", + args: "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000758504c4f44444500000000000000000000000000000000000000000000000000", + want: "XPLODDE", + }, + { + name: "2", + args: "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000022426974436c617665202d20436f6e73756d657220416374697669747920546f6b656e00000000000000", + want: "BitClave - Consumer Activity Token", + }, + { + name: "short", + args: "0x44616920537461626c65636f696e2076312e3000000000000000000000000000", + want: "Dai Stablecoin v1.0", + }, + { + name: "short2", + args: "0x44616920537461626c65636f696e2076312e3020444444444444444444444444", + want: "Dai Stablecoin v1.0 DDDDDDDDDDDD", + }, + { + name: "long", + args: "0x556e6973776170205631000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + want: "Uniswap V1", + }, + { + name: "garbage", + args: "0x2234880850896048596206002535425366538144616734015984380565810000", + want: "", + }, + { + name: "garbage", + args: "6080604052600436106100225760003560e01c80630cbcae701461003957610031565b366100315761002f610077565b005b61002f610077565b34801561004557600080fd5b5061004e61014e565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b7f000000000000000000000000000000000000000000000000000000000000000061011c565b60043560601b60601c6bca11c0de15dead10cced00006000195460a01c036100e9577f696d706c6f63000000000000000000000000000000000000000000000000000060005260206000fd5b8060001955005b60405136810160405236600082376000803683600019545af43d6000833e80610117573d82fd5b503d81f35b80330361014357602436036101435763ca11c0de60003560e01c036101435761014361009d565b61014b6100f0565b50565b600073ffffffffffffffffffffffffffffffffffffffff7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff541660005260206000f3fea2646970667358221220f27ad3f3b75609baa5d26d65ec1001c4a59f38e89088d6b47517c1cd1faf22ab64736f6c634300080d0033", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseSimpleStringProperty(tt.args) + // the addresses could have different case + if got != tt.want { + t.Errorf("parseSimpleStringProperty = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetSignatureFromData(t *testing.T) { + tests := []struct { + name string + data string + want uint32 + }{ + { + name: "0x9e53a69a", + data: "0x9e53a69a000000000000000000000000000000000000000000000", + want: 2656282266, + }, + { + name: "9e53a69b", + data: "9e53a69b000000000000000000000000000000000000000000000", + want: 2656282267, + }, + { + name: "0x9e53 short", + data: "0x9e53", + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetSignatureFromData(tt.data); got != tt.want { + t.Errorf("GetSignatureFromData() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseInputData(t *testing.T) { + signatures := []bchain.FourByteSignature{ + { + Name: "mintFighter", + Parameters: []string{}, + }, + { + Name: "cancelMultipleMakerOrders", + Parameters: []string{"uint256[]"}, + }, + { + Name: "mockRegisterFact", + Parameters: []string{"bytes32"}, + }, + { + Name: "vestingDeposits", + Parameters: []string{"address"}, + }, + { + Name: "addLiquidityETHToContract", + Parameters: []string{"address", "uint256", "uint256", "uint256", "address", "uint256"}, + }, + { + Name: "spread", + Parameters: []string{"uint256", "address[]"}, + }, + { + Name: "registerWithConfig", + Parameters: []string{"string", "address", "uint256", "bytes32", "address", "address"}, + }, + { + Name: "atomicMatch_", + Parameters: []string{"address[14]", "uint256[18]", "uint8[8]", "bytes", "bytes", "bytes", "bytes", "bytes", "bytes", "uint8[2]", "bytes32[5]"}, + }, + { + Name: "transmitAndSellTokenForEth", + Parameters: []string{"address", "uint256", "uint256", "uint256", "address", "(uint8,bytes32,bytes32)", "bytes"}, + }, + { + Name: "execute", + Parameters: []string{"bytes", "bytes[]", "uint256"}, + }, + } + tests := []struct { + name string + signatures *[]bchain.FourByteSignature + data string + want *bchain.EthereumParsedInputData + wantErr bool + }{ + { + name: "transfer", + signatures: &signatures, + data: "", + want: &bchain.EthereumParsedInputData{ + Name: "Transfer", + }, + }, + { + name: "mintFighter", + signatures: &signatures, + data: "0xa19b9082", + want: &bchain.EthereumParsedInputData{ + MethodId: "0xa19b9082", + Name: "Mint Fighter", + Function: "mintFighter()", + Params: []bchain.EthereumParsedInputParam{}, + }, + }, + { + name: "mockRegisterFact", + signatures: &signatures, + data: "0xf69507abdc8fa8fe57a22de66a1d5898496c524068cb04c31f72497b3ac9f3b449e58725", + want: &bchain.EthereumParsedInputData{ + MethodId: "0xf69507ab", + Name: "Mock Register Fact", + Function: "mockRegisterFact(bytes32)", + Params: []bchain.EthereumParsedInputParam{ + { + Type: "bytes32", + Values: []string{"0xdc8fa8fe57a22de66a1d5898496c524068cb04c31f72497b3ac9f3b449e58725"}, + }, + }, + }, + }, + { + name: "cancelMultipleMakerOrders", + signatures: &signatures, + data: "0x9e53a69a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000017f62f8db30", + want: &bchain.EthereumParsedInputData{ + MethodId: "0x9e53a69a", + Name: "Cancel Multiple Maker Orders", + Function: "cancelMultipleMakerOrders(uint256[])", + Params: []bchain.EthereumParsedInputParam{ + { + Type: "uint256[]", + Values: []string{"1646632950576"}, + }, + }, + }, + }, + { + name: "addLiquidityETHToContract", + signatures: &signatures, + data: "0xf305d719000000000000000000000000b80e5aaa2131c07568128f68b8538ed3c8951234000000000000000000000000000000000000007e37be2022c0914b2680000000000000000000000000000000000000000000007e37be2022c0914b26800000000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000009f64b014ca26f2def573246543dd1115b229e4f400000000000000000000000000000000000000000000000000000000623f56f8", + want: &bchain.EthereumParsedInputData{ + MethodId: "0xf305d719", + Name: "Add Liquidity ETH To Contract", + Function: "addLiquidityETHToContract(address, uint256, uint256, uint256, address, uint256)", + Params: []bchain.EthereumParsedInputParam{ + { + Type: "address", + Values: []string{"0xB80e5AaA2131c07568128f68b8538eD3C8951234"}, + }, + { + Type: "uint256", + Values: []string{"10000000000000000000000000000000"}, + }, + { + Type: "uint256", + Values: []string{"10000000000000000000000000000000"}, + }, + { + Type: "uint256", + Values: []string{"1000000000000000000"}, + }, + { + Type: "address", + Values: []string{"0x9f64B014CA26F2DeF573246543DD1115b229e4F4"}, + }, + { + Type: "uint256", + Values: []string{"1648318200"}, + }, + }, + }, + }, + { + name: "addLiquidityETHToContract data don't match - too long", + signatures: &signatures, + data: "0xf305d719000000000000000000000000b80e5aaa2131c07568128f68b8538ed3c8951234000000000000000000000000000000000000007e37be2022c0914b2680000000000000000000000000000000000000000000007e37be2022c0914b26800000000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000009f64b014ca26f2def573246543dd1115b229e4f400000000000000000000000000000000000000000000000000000000623f56f800000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + want: &bchain.EthereumParsedInputData{ + MethodId: "0xf305d719", + }, + }, + { + name: "addLiquidityETHToContract data don't match - too short", + signatures: &signatures, + data: "0xf305d719000000000000000000000000b80e5aaa2131c07568128f68b8538ed3c8951234000000000000000000000000000000000000007e37be2022c0914b2680000000000000000000000000000000000000000000007e37be2022c0914b26800000000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000009f64b014ca26f2def573246543dd1115b229e4f4", + want: &bchain.EthereumParsedInputData{ + MethodId: "0xf305d719", + }, + }, + { + name: "spread", + signatures: &signatures, + data: "0xcd51b093000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000200000000000000000000000048c999d9206fcf2a0ecde10049de6dc2d1704bb2000000000000000000000000d2dae6b2309ada5d4c983b4c7d2c942452adc759", + want: &bchain.EthereumParsedInputData{ + MethodId: "0xcd51b093", + Name: "Spread", + Function: "spread(uint256, address[])", + Params: []bchain.EthereumParsedInputParam{ + { + Type: "uint256", + Values: []string{"100000000000000000"}, + }, + { + Type: "address[]", + Values: []string{"0x48c999d9206fcf2A0ecdE10049de6Dc2d1704Bb2", "0xD2DAE6B2309aDa5d4c983B4c7D2c942452aDC759"}, + }, + }, + }, + }, + { + name: "atomicMatch_", // mainnet tx 0x57aff22b0f812e05467fb73caec8ac0364a535382496e5f64eb9df9fb32bd85f + signatures: &signatures, + data: "0xab834bab0000000000000000000000007f268357a8c2552623316e2562d90e642bb538e50000000000000000000000001676b0ab0aeb83122c58abc3d6a50b6c4a9d376300000000000000000000000024c57fbb5c260edf158583818177cfd5c2dec4700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000baf2127b49fc93cbca6269fade0f7f31df4c88a7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007f268357a8c2552623316e2562d90e642bb538e500000000000000000000000024c57fbb5c260edf158583818177cfd5c2dec47000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005b3256965e7c3cf26e11fcaf296dfc8807c01073000000000000000000000000baf2127b49fc93cbca6269fade0f7f31df4c88a70000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002ee000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000062531f6400000000000000000000000000000000000000000000000000000000000000000227db897c05fe6409bc72c6bee932b99a92ca45e155cf85e763424e7a3ee61500000000000000000000000000000000000000000000000000000000000002ee000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002386f26fc10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000625313f800000000000000000000000000000000000000000000000000000000627aa14b79166058af7dd96e2190730f926c56d6131af9d72b4dd2138b58c30e268c7f300000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000006a000000000000000000000000000000000000000000000000000000000000007c000000000000000000000000000000000000000000000000000000000000008e00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000b200000000000000000000000000000000000000000000000000000000000000b20000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000001c77e6196859305642ea4751b9597a9507472acb04b9f1f4759aa0f27af41edd8960513f1649f58782cacce26b1341575b584594f940bba0614aff302d25b4b10477e6196859305642ea4751b9597a9507472acb04b9f1f4759aa0f27af41edd8960513f1649f58782cacce26b1341575b584594f940bba0614aff302d25b4b104000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e4fb16a59500000000000000000000000000000000000000000000000000000000000000000000000000000000000000001676b0ab0aeb83122c58abc3d6a50b6c4a9d3763000000000000000000000000f25f4f4f6517101dc947d1c0370571ebdd25f14a00000000000000000000000000000000000000000000000000000000000002c7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e4fb16a59500000000000000000000000024c57fbb5c260edf158583818177cfd5c2dec4700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f25f4f4f6517101dc947d1c0370571ebdd25f14a00000000000000000000000000000000000000000000000000000000000002c7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e400000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e4000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + want: &bchain.EthereumParsedInputData{ + MethodId: "0xab834bab", + Name: "Atomic Match_", + Function: "atomicMatch_(address[14], uint256[18], uint8[8], bytes, bytes, bytes, bytes, bytes, bytes, uint8[2], bytes32[5])", + Params: []bchain.EthereumParsedInputParam{ + { + Type: "address[14]", + Values: []string{ + "0x7f268357A8c2552623316e2562D90e642bB538E5", "0x1676b0AB0Aeb83122C58ABC3d6a50B6c4A9d3763", "0x24C57FBB5c260EDf158583818177Cfd5C2dec470", "0x0000000000000000000000000000000000000000", + "0xBAf2127B49fC93CbcA6269FAdE0F7F31dF4c88a7", "0x0000000000000000000000000000000000000000", "0x0000000000000000000000000000000000000000", "0x7f268357A8c2552623316e2562D90e642bB538E5", + "0x24C57FBB5c260EDf158583818177Cfd5C2dec470", "0x0000000000000000000000000000000000000000", "0x5b3256965e7C3cF26E11FCAf296DfC8807C01073", "0xBAf2127B49fC93CbcA6269FAdE0F7F31dF4c88a7", + "0x0000000000000000000000000000000000000000", "0x0000000000000000000000000000000000000000"}, + }, + { + Type: "uint256[18]", + Values: []string{ + "750", "0", "0", "0", "10000000000000000", "0", "1649614692", "0", "975047921716720136517384107537725863826800092678142650456874303300963329557", + "750", "0", "0", "0", "10000000000000000", "0", "1649611768", "1652203851", "54769390272606378508076535204478407261307419838517394120712398796227861053232"}, + }, + { + Type: "uint8[8]", + Values: []string{"1", "0", "0", "1", "1", "1", "0", "1"}, + }, + { + Type: "bytes", + Values: []string{"0xfb16a59500000000000000000000000000000000000000000000000000000000000000000000000000000000000000001676b0ab0aeb83122c58abc3d6a50b6c4a9d3763000000000000000000000000f25f4f4f6517101dc947d1c0370571ebdd25f14a00000000000000000000000000000000000000000000000000000000000002c7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000000"}, + }, + { + Type: "bytes", + Values: []string{"0xfb16a59500000000000000000000000024c57fbb5c260edf158583818177cfd5c2dec4700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f25f4f4f6517101dc947d1c0370571ebdd25f14a00000000000000000000000000000000000000000000000000000000000002c7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000000"}, + }, + { + Type: "bytes", + Values: []string{"0x00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}, + }, + { + Type: "bytes", + Values: []string{"0x000000000000000000000000000000000000000000000000000000000000000000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}, + }, + { + Type: "bytes", + Values: []string{""}, + }, + { + Type: "bytes", + Values: []string{""}, + }, + { + Type: "uint8[2]", + Values: []string{"28", "28"}, + }, + { + Type: "bytes32[5]", + Values: []string{"0x77e6196859305642ea4751b9597a9507472acb04b9f1f4759aa0f27af41edd89", "0x60513f1649f58782cacce26b1341575b584594f940bba0614aff302d25b4b104", + "0x77e6196859305642ea4751b9597a9507472acb04b9f1f4759aa0f27af41edd89", "0x60513f1649f58782cacce26b1341575b584594f940bba0614aff302d25b4b104", + "0x0000000000000000000000000000000000000000000000000000000000000000"}, + }, + }, + }, + }, + { + name: "registerWithConfig", + signatures: &signatures, + data: "0xf7a1696300000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000769cbf44073741ccb4c39c945402130b46fa8a70000000000000000000000000000000000000000000000000000000012cf35707a8c22626793047f41a428e815e2bb12ced6d5db4246a8b0bda488c541647bef0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba410000000000000000000000000769cbf44073741ccb4c39c945402130b46fa8a700000000000000000000000000000000000000000000000000000000000000076d6f6e7369746100000000000000000000000000000000000000000000000000", + want: &bchain.EthereumParsedInputData{ + MethodId: "0xf7a16963", + Name: "Register With Config", + Function: "registerWithConfig(string, address, uint256, bytes32, address, address)", + Params: []bchain.EthereumParsedInputParam{ + { + Type: "string", + Values: []string{"monsita"}, + }, + { + Type: "address", + Values: []string{"0x0769cBf44073741cCb4C39c945402130B46fa8A7"}, + }, + { + Type: "uint256", + Values: []string{"315569520"}, + }, + { + Type: "bytes32", + Values: []string{"0x7a8c22626793047f41a428e815e2bb12ced6d5db4246a8b0bda488c541647bef"}, + }, + { + Type: "address", + Values: []string{"0x4976fb03C32e5B8cfe2b6cCB31c09Ba78EBaBa41"}, + }, + { + Type: "address", + Values: []string{"0x0769cBf44073741cCb4C39c945402130B46fa8A7"}, + }, + }, + }, + }, + { + name: "execute", + signatures: &signatures, + data: "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000063fd167b00000000000000000000000000000000000000000000000000000000000000010800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000021e19e0c9bab2400000000000000000000000000000000000000000000000000000000000002fa5e9a300000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000003000000000000000000000000cda4e840411c00a614ad9205caec807c7458a0e3000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + want: &bchain.EthereumParsedInputData{ + MethodId: "0x3593564c", + Name: "Execute", + Function: "execute(bytes, bytes[], uint256)", + Params: []bchain.EthereumParsedInputParam{ + { + Type: "bytes", + Values: []string{"0x08"}, + }, + { + Type: "bytes[]", + Values: []string{"0x000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000021e19e0c9bab2400000000000000000000000000000000000000000000000000000000000002fa5e9a300000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000003000000000000000000000000cda4e840411c00a614ad9205caec807c7458a0e3000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"}, + }, + { + Type: "uint256", + Values: []string{"1677530747"}, + }, + }, + }, + }, + { + name: "execute2", + signatures: &signatures, + data: "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000063ffd82300000000000000000000000000000000000000000000000000000000000000020b080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000006f05b59d3b20000000000000000000000000000000000000000000000491478480c282e75df8b5700000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000f0f9d895aca5c8678f706fb8216fa22957685a13", + want: &bchain.EthereumParsedInputData{ + MethodId: "0x3593564c", + Name: "Execute", + Function: "execute(bytes, bytes[], uint256)", + Params: []bchain.EthereumParsedInputParam{ + { + Type: "bytes", + Values: []string{"0x0b08"}, + }, + { + Type: "bytes[]", + Values: []string{ + "0x000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000006f05b59d3b20000", + "0x000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000006f05b59d3b20000000000000000000000000000000000000000000000491478480c282e75df8b5700000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000f0f9d895aca5c8678f706fb8216fa22957685a13", + }, + }, + { + Type: "uint256", + Values: []string{"1677711395"}, + }, + }, + }, + }, + } + parser := NewEthereumParser(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) + } + }) + } +} + +func Test_getEnsRecord(t *testing.T) { + tests := []struct { + name string + log rpcLogWithTxHash + want *bchain.AddressAliasRecord + }{ + { + name: "unraveled", + log: rpcLogWithTxHash{ + RpcLog: bchain.RpcLog{ + Address: "0x283Af0B28c62C092C9727F1Ee09c02CA627EB7F5", + Topics: []string{ + "0xca6abbe9d7f11422cb6ca7629fbf6fe9efb1c621f71ce8f02b9f2a230097404f", + "0x40ce2aa8cd9ee9fef4bf3a68abab7fbcceb6bac89370518caf6a602cefe836bd", + "0x0000000000000000000000002c630b16aa53ae0189880e15c23323688acb607c", + }, + Data: "0x00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000017629245f5a86f0000000000000000000000000000000000000000000000000000000069dbb21d0000000000000000000000000000000000000000000000000000000000000009756e726176656c65640000000000000000000000000000000000000000000000", + }, + }, + want: &bchain.AddressAliasRecord{Address: "0x2C630b16Aa53ae0189880e15C23323688acb607c", Name: "unraveled"}, + }, + { + name: "4x unraveled", + log: rpcLogWithTxHash{ + RpcLog: bchain.RpcLog{ + Address: "0x283Af0B28c62C092C9727F1Ee09c02CA627EB7F5", + Topics: []string{ + "0xca6abbe9d7f11422cb6ca7629fbf6fe9efb1c621f71ce8f02b9f2a230097404f", + "0x40ce2aa8cd9ee9fef4bf3a68abab7fbcceb6bac89370518caf6a602cefe836bd", + "0x0000000000000000000000002c630b16aa53ae0189880e15c23323688acb607c", + }, + Data: "0x00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000017629245f5a86f0000000000000000000000000000000000000000000000000000000069dbb21d0000000000000000000000000000000000000000000000000000000000000024756e726176656c6564756e726176656c6564756e726176656c6564756e726176656c656400000000000000000000000000000000000000000000000000000000", + }, + }, + want: &bchain.AddressAliasRecord{Address: "0x2C630b16Aa53ae0189880e15C23323688acb607c", Name: "unraveledunraveledunraveledunraveled"}, + }, + { + name: "no signature", + log: rpcLogWithTxHash{ + RpcLog: bchain.RpcLog{ + Address: "0x283Af0B28c62C092C9727F1Ee09c02CA627EB7F5", + Topics: []string{ + "0xca6abbe9d7f11422cb6ca7629fbf6fe9efb1c621f71ce8f02b9f2a230097404e", + "0x40ce2aa8cd9ee9fef4bf3a68abab7fbcceb6bac89370518caf6a602cefe836bd", + "0x0000000000000000000000002c630b16aa53ae0189880e15c23323688acb607c", + }, + Data: "0x00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000017629245f5a86f0000000000000000000000000000000000000000000000000000000069dbb21d0000000000000000000000000000000000000000000000000000000000000009756e726176656c65640000000000000000000000000000000000000000000000", + }, + }, + want: nil, + }, + { + name: "name length does not match", + log: rpcLogWithTxHash{ + RpcLog: bchain.RpcLog{ + Address: "0x283Af0B28c62C092C9727F1Ee09c02CA627EB7F5", + Topics: []string{ + "0xca6abbe9d7f11422cb6ca7629fbf6fe9efb1c621f71ce8f02b9f2a230097404f", + "0x40ce2aa8cd9ee9fef4bf3a68abab7fbcceb6bac89370518caf6a602cefe836bd", + "0x0000000000000000000000002c630b16aa53ae0189880e15c23323688acb607c", + }, + Data: "0x00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000017629245f5a86f0000000000000000000000000000000000000000000000000000000069dbb21d0000000000000000000000000000000000000000000000000000000000000ff9756e726176656c65640000000000000000000000000000000000000000000000", + }, + }, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getEnsRecord(&tt.log); !reflect.DeepEqual(got, tt.want) { + t.Errorf("getEnsRecord() = %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 c5b53168c0..30085cd8c0 100644 --- a/bchain/coins/eth/ethparser.go +++ b/bchain/coins/eth/ethparser.go @@ -4,32 +4,89 @@ import ( "encoding/hex" "math/big" "strconv" - - vlq "github.com/bsm/go-vlq" + "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" - "github.com/syscoin/blockbook/bchain" + "google.golang.org/protobuf/proto" ) -// EthereumTypeAddressDescriptorLen - in case of EthereumType, the AddressDescriptor has fixed length +// EthereumTypeAddressDescriptorLen - the AddressDescriptor of EthereumType has fixed length const EthereumTypeAddressDescriptorLen = 20 +// EthereumTypeTxidLen - the length of Txid +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) *EthereumParser { - return &EthereumParser{&bchain.BaseParser{ - BlockAddressesToKeep: b, - AmountDecimalPoint: EtherAmountDecimalPoint, - }} +func NewEthereumParser(b int, addressAliases bool) *EthereumParser { + 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 { @@ -42,54 +99,23 @@ type rpcHeader struct { Nonce string `json:"nonce"` } -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"` - // Signature values - ignored - // V string `json:"v"` - // R string `json:"r"` - // S string `json:"s"` -} - -type rpcLog struct { - Address string `json:"address"` - Topics []string `json:"topics"` - Data string `json:"data"` -} - type rpcLogWithTxHash struct { - rpcLog + bchain.RpcLog Hash string `json:"transactionHash"` } -type rpcReceipt struct { - GasUsed string `json:"gasUsed"` - Status string `json:"status"` - Logs []*rpcLog `json:"logs"` -} - -type completeTransaction struct { - Tx *rpcTransaction `json:"tx"` - Receipt *rpcReceipt `json:"receipt,omitempty"` -} - type rpcBlockTransactions struct { - Transactions []rpcTransaction `json:"transactions"` + Transactions []bchain.RpcTransaction `json:"transactions"` } 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) @@ -97,7 +123,7 @@ func ethNumber(n string) (int64, error) { return 0, errors.Errorf("Not a number: '%v'", n) } -func (p *EthereumParser) ethTxToTx(tx *rpcTransaction, receipt *rpcReceipt, 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 @@ -105,26 +131,41 @@ func (p *EthereumParser) ethTxToTx(tx *rpcTransaction, receipt *rpcReceipt, bloc ) 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) + } + } + } + if internalData != nil { + // ignore empty internal data + if internalData.Type == bchain.CALL && len(internalData.Transfers) == 0 && len(internalData.Error) == 0 { + internalData = nil + } else { + if fixEIP55 { + for i := range internalData.Transfers { + it := &internalData.Transfers[i] + it.From = p.FormatAddressFunc(it.From) + it.To = p.FormatAddressFunc(it.To) + } } } } - ct := completeTransaction{ - Tx: tx, - Receipt: receipt, + ct := bchain.EthereumSpecificData{ + Tx: tx, + InternalData: internalData, + Receipt: receipt, } vs, err := hexutil.DecodeBig(tx.Value) if err != nil { @@ -224,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 @@ -255,10 +296,11 @@ func hexEncodeBig(b []byte) string { } // PackTx packs transaction to byte array +// completeTransaction.InternalData are not packed, they are stored in a different table func (p *EthereumParser) PackTx(tx *bchain.Tx, height uint32, blockTime int64) ([]byte, error) { var err error var n uint64 - r, ok := tx.CoinSpecificData.(completeTransaction) + r, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) if !ok { return nil, errors.New("Missing CoinSpecificData") } @@ -288,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) // } @@ -346,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) } @@ -357,10 +435,10 @@ func (p *EthereumParser) UnpackTx(buf []byte) (*bchain.Tx, uint32, error) { if err != nil { return nil, 0, err } - rt := rpcTransaction{ + 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), @@ -368,45 +446,73 @@ 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), } - var rr *rpcReceipt + 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([]*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] = &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 = &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) } } - tx, err := p.ethTxToTx(&rt, rr, int64(pt.BlockTime), 0, false) + // TODO handle internal transactions + 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 } // PackedTxidLen returns length in bytes of packed txid func (p *EthereumParser) PackedTxidLen() int { - return 32 + return EthereumTypeTxidLen } // PackTxid packs txid to byte array @@ -443,7 +549,7 @@ func (p *EthereumParser) GetChainType() bchain.ChainType { // GetHeightFromTx returns ethereum specific data from bchain.Tx func GetHeightFromTx(tx *bchain.Tx) (uint32, error) { var bn string - csd, ok := tx.CoinSpecificData.(completeTransaction) + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) if !ok { return 0, errors.New("Missing CoinSpecificData") } @@ -455,16 +561,16 @@ func GetHeightFromTx(tx *bchain.Tx) (uint32, error) { return uint32(n), nil } -// EthereumTypeGetErc20FromTx returns Erc20 data from bchain.Tx -func (p *EthereumParser) EthereumTypeGetErc20FromTx(tx *bchain.Tx) ([]bchain.Erc20Transfer, error) { - var r []bchain.Erc20Transfer +// EthereumTypeGetTokenTransfersFromTx returns contract transfers from bchain.Tx +func (p *EthereumParser) EthereumTypeGetTokenTransfersFromTx(tx *bchain.Tx) (bchain.TokenTransfers, error) { + var r bchain.TokenTransfers var err error - csd, ok := tx.CoinSpecificData.(completeTransaction) + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) if ok { if csd.Receipt != nil { - r, err = erc20GetTransfersFromLog(csd.Receipt.Logs) + r, err = contractGetTransfersFromLog(csd.Receipt.Logs) } else { - r, err = erc20GetTransfersFromTx(csd.Tx) + r, err = contractGetTransfersFromTx(csd.Tx) } if err != nil { return nil, err @@ -473,93 +579,90 @@ func (p *EthereumParser) EthereumTypeGetErc20FromTx(tx *bchain.Tx) ([]bchain.Erc return r, nil } -// 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) +// FormatAddressAlias adds .eth to a name alias +func (p *EthereumParser) FormatAddressAlias(address string, name string) string { + return name + p.EnsSuffix } -// GetEthereumTxDataFromSpecificData returns EthereumTxData from coinSpecificData -func GetEthereumTxDataFromSpecificData(coinSpecificData interface{}) *EthereumTxData { - etd := EthereumTxData{Status: TxStatusPending} - csd, ok := coinSpecificData.(completeTransaction) +// 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 } -// Block index +// 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" -func (p *EthereumParser) PackBlockInfo(block *bchain.DbBlockInfo) ([]byte, error) { - packed := make([]byte, 0, 64) - varBuf := make([]byte, vlq.MaxLen64) - b, err := p.PackBlockHash(block.Hash) - if err != nil { - return nil, err +// ParseErrorFromOutput takes output field from internal transaction data and extracts an error message from it +// the output must have errorOutputSignature to be parsed +func ParseErrorFromOutput(output string) string { + if has0xPrefix(output) { + output = output[2:] + } + if len(output) < 8+64+64+64 || output[:8] != errorOutputSignature { + return "" } - packed = append(packed, b...) - packed = append(packed, p.BaseParser.PackUint(uint32(block.Time))...) - l := p.BaseParser.PackVaruint(uint(block.Txs), varBuf) - packed = append(packed, varBuf[:l]...) - l = p.BaseParser.PackVaruint(uint(block.Size), varBuf) - packed = append(packed, varBuf[:l]...) - return packed, nil + return parseSimpleStringProperty(output[8:]) } -func (p *EthereumParser) UnpackBlockInfo(buf []byte) (*bchain.DbBlockInfo, error) { - pl := p.PackedTxidLen() - // minimum length is PackedTxidLen + 4 bytes time + 1 byte txs + 1 byte size - if len(buf) < pl+4+2 { - return nil, nil +// PackInternalTransactionError packs common error messages to single byte to save DB space +func PackInternalTransactionError(e string) string { + if e == "execution reverted" { + return "\x01" } - txid, err := p.UnpackBlockHash(buf[:pl]) - if err != nil { - return nil, err + if e == "out of gas" { + return "\x02" } - t := p.BaseParser.UnpackUint(buf[pl:]) - txs, l := p.BaseParser.UnpackVaruint(buf[pl+4:]) - size, _ := p.BaseParser.UnpackVaruint(buf[pl+4+l:]) - return &bchain.DbBlockInfo{ - Hash: txid, - Time: int64(t), - Txs: uint32(txs), - Size: uint32(size), - }, nil -} \ No newline at end of file + if e == "contract creation code storage out of gas" { + return "\x03" + } + if e == "max code size exceeded" { + return "\x04" + } + + return e +} + +// UnpackInternalTransactionError unpacks common error messages packed by PackInternalTransactionError +func UnpackInternalTransactionError(data []byte) string { + e := string(data) + e = strings.ReplaceAll(e, "\x01", "Reverted. ") + e = strings.ReplaceAll(e, "\x02", "Out of gas. ") + e = strings.ReplaceAll(e, "\x03", "Contract creation code storage out of gas. ") + e = strings.ReplaceAll(e, "\x04", "Max code size exceeded. ") + return strings.TrimSpace(e) +} diff --git a/bchain/coins/eth/ethparser_test.go b/bchain/coins/eth/ethparser_test.go index accad4ea69..e038cf00a8 100644 --- a/bchain/coins/eth/ethparser_test.go +++ b/bchain/coins/eth/ethparser_test.go @@ -9,8 +9,8 @@ import ( "reflect" "testing" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/tests/dbtestdata" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/tests/dbtestdata" ) func TestEthParser_GetAddrDescFromAddress(t *testing.T) { @@ -54,7 +54,7 @@ func TestEthParser_GetAddrDescFromAddress(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := NewEthereumParser(1) + p := NewEthereumParser(1, false) got, err := p.GetAddrDescFromAddress(tt.args.address) if (err != nil) != tt.wantErr { t.Errorf("EthParser.GetAddrDescFromAddress() error = %v, wantErr %v", err, tt.wantErr) @@ -89,23 +89,26 @@ func init() { }, }, }, - CoinSpecificData: completeTransaction{ - Tx: &rpcTransaction{ - AccountNonce: "0xb26c", - GasPrice: "0x430e23400", - GasLimit: "0x5208", - To: "0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f", - Value: "0x1bc0159d530e6000", - Payload: "0x", - Hash: "0xcd647151552b5132b2aef7c9be00dc6f73afc5901dde157aab131335baaa853b", - BlockNumber: "0x41eee8", - From: "0x3E3a3D69dc66bA10737F531ed088954a9EC89d97", - TransactionIndex: "0xa", + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ + 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: &rpcReceipt{ + Receipt: &bchain.RpcReceipt{ GasUsed: "0x5208", Status: "0x1", - Logs: []*rpcLog{}, + Logs: []*bchain.RpcLog{}, }, }, } @@ -127,22 +130,25 @@ func init() { }, }, }, - CoinSpecificData: completeTransaction{ - Tx: &rpcTransaction{ - AccountNonce: "0xd0", - GasPrice: "0x9502f9000", - GasLimit: "0x130d5", - To: "0x4af4114F73d1c1C903aC9E0361b379D1291808A2", - Value: "0x0", - Payload: "0xa9059cbb000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f00000000000000000000000000000000000000000000021e19e0c9bab2400000", - Hash: "0xa9cd088aba2131000da6f38a33c20169baee476218deea6b78720700b895b101", - BlockNumber: "0x41eee8", - From: "0x20cD153de35D469BA46127A0C8F18626b59a256A", - TransactionIndex: "0x0"}, - Receipt: &rpcReceipt{ + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ + 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", - Logs: []*rpcLog{ + Logs: []*bchain.RpcLog{ { Address: "0x4af4114F73d1c1C903aC9E0361b379D1291808A2", Data: "0x00000000000000000000000000000000000000000000021e19e0c9bab2400000", @@ -174,8 +180,8 @@ func init() { }, }, }, - CoinSpecificData: completeTransaction{ - Tx: &rpcTransaction{ + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ AccountNonce: "0xb26c", GasPrice: "0x430e23400", GasLimit: "0x5208", @@ -187,10 +193,10 @@ func init() { From: "0x3E3a3D69dc66bA10737F531ed088954a9EC89d97", TransactionIndex: "0xa", }, - Receipt: &rpcReceipt{ + Receipt: &bchain.RpcReceipt{ GasUsed: "0x5208", Status: "0x0", - Logs: []*rpcLog{}, + Logs: []*bchain.RpcLog{}, }, }, } @@ -212,8 +218,8 @@ func init() { }, }, }, - CoinSpecificData: completeTransaction{ - Tx: &rpcTransaction{ + CoinSpecificData: bchain.EthereumSpecificData{ + Tx: &bchain.RpcTransaction{ AccountNonce: "0xb26c", GasPrice: "0x430e23400", GasLimit: "0x5208", @@ -225,10 +231,10 @@ func init() { From: "0x3E3a3D69dc66bA10737F531ed088954a9EC89d97", TransactionIndex: "0xa", }, - Receipt: &rpcReceipt{ + Receipt: &bchain.RpcReceipt{ GasUsed: "0x5208", Status: "", - Logs: []*rpcLog{}, + Logs: []*bchain.RpcLog{}, }, }, } @@ -285,7 +291,7 @@ func TestEthereumParser_PackTx(t *testing.T) { want: dbtestdata.EthTx1NoStatusPacked, }, } - p := NewEthereumParser(1) + p := NewEthereumParser(1, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := p.PackTx(tt.args.tx, tt.args.height, tt.args.blockTime) @@ -338,7 +344,7 @@ func TestEthereumParser_UnpackTx(t *testing.T) { want1: 4321000, }, } - p := NewEthereumParser(1) + p := NewEthereumParser(1, false) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { b, err := hex.DecodeString(tt.args.hex) @@ -351,8 +357,8 @@ func TestEthereumParser_UnpackTx(t *testing.T) { return } // DeepEqual has problems with pointers in completeTransaction - gs := got.CoinSpecificData.(completeTransaction) - ws := tt.want.CoinSpecificData.(completeTransaction) + gs := got.CoinSpecificData.(bchain.EthereumSpecificData) + ws := tt.want.CoinSpecificData.(bchain.EthereumSpecificData) gc := *got wc := *tt.want gc.CoinSpecificData = nil @@ -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,10 +445,104 @@ 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) } }) } } + +func TestEthereumParser_ParseErrorFromOutput(t *testing.T) { + tests := []struct { + name string + output string + want string + }{ + { + name: "ParseErrorFromOutput 1", + output: "0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000031546f74616c206e756d626572206f662067726f757073206d7573742062652067726561746572207468616e207a65726f2e000000000000000000000000000000", + want: "Total number of groups must be greater than zero.", + }, + { + name: "ParseErrorFromOutput 2", + output: "0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000126e6f7420656e6f7567682062616c616e63650000000000000000000000000000", + want: "not enough balance", + }, + { + name: "ParseErrorFromOutput empty", + output: "", + want: "", + }, + { + name: "ParseErrorFromOutput short", + output: "0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000012", + want: "", + }, + { + name: "ParseErrorFromOutput invalid signature", + output: "0x08c379b0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000126e6f7420656e6f7567682062616c616e63650000000000000000000000000000", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseErrorFromOutput(tt.output) + if got != tt.want { + t.Errorf("EthereumParser.ParseErrorFromOutput() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestEthereumParser_PackInternalTransactionError_UnpackInternalTransactionError(t *testing.T) { + tests := []struct { + name string + original string + packed string + unpacked string + }{ + { + name: "execution reverted", + original: "execution reverted", + packed: "\x01", + unpacked: "Reverted.", + }, + { + name: "out of gas", + original: "out of gas", + packed: "\x02", + unpacked: "Out of gas.", + }, + { + name: "contract creation code storage out of gas", + original: "contract creation code storage out of gas", + packed: "\x03", + unpacked: "Contract creation code storage out of gas.", + }, + { + name: "max code size exceeded", + original: "max code size exceeded", + packed: "\x04", + unpacked: "Max code size exceeded.", + }, + { + name: "unknown error", + original: "unknown error", + packed: "unknown error", + unpacked: "unknown error", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + packed := PackInternalTransactionError(tt.original) + if packed != tt.packed { + t.Errorf("EthereumParser.PackInternalTransactionError() = %v, want %v", packed, tt.packed) + } + unpacked := UnpackInternalTransactionError([]byte(packed)) + if unpacked != tt.unpacked { + t.Errorf("EthereumParser.UnpackInternalTransactionError() = %v, want %v", unpacked, tt.unpacked) + } + }) + } +} diff --git a/bchain/coins/eth/ethrpc.go b/bchain/coins/eth/ethrpc.go index b057c77c24..eac2a5dabe 100644 --- a/bchain/coins/eth/ethrpc.go +++ b/bchain/coins/eth/ethrpc.go @@ -2,63 +2,210 @@ package eth import ( "context" + "encoding/hex" "encoding/json" + stdErrors "errors" "fmt" + "io" "math/big" + "net/http" + "net/url" "strconv" + "strings" "sync" + "sync/atomic" "time" - ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum" ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" - ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/common" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" + "golang.org/x/crypto/sha3" + "golang.org/x/sync/singleflight" ) -// EthereumNet type specifies the type of ethereum network -type EthereumNet uint32 +// Network type specifies the type of ethereum network +type Network uint32 const ( // MainNet is production network - MainNet EthereumNet = 1 - // TestNet is Ropsten test network - TestNet EthereumNet = 3 + MainNet Network = 1 + // 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 + + // defaultRPCTimeoutSeconds is used when rpc_timeout is unset or non-positive. + // A zero b.Timeout makes context.WithTimeout expire immediately (breaking every + // call), so a finite floor is enforced rather than trusting the config. Kept + // above the 10s trace_timeout default so the fallback still lets a block's + // internal-data trace finish. + defaultRPCTimeoutSeconds = 15 + + // 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"` - MempoolTxTimeoutHours int `json:"mempoolTxTimeoutHours"` - QueryBackendOnMempoolResync bool `json:"queryBackendOnMempoolResync"` + 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"` + // MissingBlockRetry overrides the sync-worker missing-block retry policy + // per chain. All fields are optional; missing fields use built-in defaults. + MissingBlockRetry *bchain.MissingBlockRetry `json:"missingBlockRetry,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 *ethclient.Client - rpc *rpc.Client - timeout time.Duration - Parser *EthereumParser - Mempool *bchain.MempoolEthereumType - mempoolInitialized bool - bestHeaderLock sync.Mutex - bestHeader *ethtypes.Header - bestHeaderTime time.Time - chanNewBlock chan *ethtypes.Header - newBlockSubscription *rpc.ClientSubscription - chanNewTx chan ethcommon.Hash - newTxSubscription *rpc.ClientSubscription - ChainConfig *Configuration + 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 + // 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{} + // subscribeReadersOnce guards the long-lived consumer goroutines (tip notifier, + // tip watchdog and the NewBlock/NewTx channel readers) so reconnectRPC -> + // subscribeEvents only re-creates the connection-bound subscriptions and never + // leaks a fresh set of readers on every reconnect. + subscribeReadersOnce sync.Once + // lastSubNotifyNs is the UnixNano of the last newHeads notification that + // advanced the cached tip (subscription path only, never watchdog polls). + // Keying liveness on tip advance, not mere arrival, lets the watchdog also + // catch a feed that keeps delivering but is stuck on one height. + lastSubNotifyNs atomic.Int64 + 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. @@ -73,126 +220,520 @@ func NewEthereumRPC(config json.RawMessage, pushHandler func(bchain.Notification if c.BlockAddressesToKeep < 100 { c.BlockAddressesToKeep = 100 } - - rc, ec, err := openRPC(c.RPCURL) - if err != nil { + 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{}, - client: ec, - rpc: rc, ChainConfig: &c, } + // 1-slot buffer ensures we only queue one "refresh tip" signal at a time. + s.newBlockNotifyCh = make(chan struct{}, 1) + + bchain.ProcessInternalTransactions = c.ProcessInternalTransactions // always create parser - s.Parser = NewEthereumParser(c.BlockAddressesToKeep) - s.timeout = time.Duration(c.RPCTimeout) * time.Second + 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 + if c.RPCTimeout <= 0 { + glog.Warningf("rpc_timeout=%d is invalid, using default %d seconds", c.RPCTimeout, defaultRPCTimeoutSeconds) + c.RPCTimeout = defaultRPCTimeoutSeconds + } + s.Timeout = time.Duration(c.RPCTimeout) * time.Second + s.PushHandler = pushHandler - // new blocks notifications handling - // the subscription is done in Initialize - s.chanNewBlock = make(chan *ethtypes.Header) - go func() { - for { - h, ok := <-s.chanNewBlock - if !ok { - break - } - glog.V(2).Info("rpc: new block header ", h.Number) - // update best header to the new header - s.bestHeaderLock.Lock() - s.bestHeader = h - s.bestHeaderTime = time.Now() - s.bestHeaderLock.Unlock() - // notify blockbook - pushHandler(bchain.NotificationNewBlock) + return s, nil +} + +// SetMetrics sets the metrics registry. The alternative send-tx provider receives the same metrics +// at construction (NewAlternativeSendTxProvider, called from InitAlternativeProviders, which runs +// after SetMetrics), so it is intentionally not assigned here - and must not be, since its reconcile +// goroutine reads provider.metrics without synchronization, so that field stays write-once. +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() +} + +// MissingBlockRetryOverride exposes the per-chain sync-worker retry override +// (or nil to use built-in defaults). Consumed by blockbook.go at SyncWorker +// construction via a duck-typed interface assertion. +func (b *EthereumRPC) MissingBlockRetryOverride() *bchain.MissingBlockRetry { + if b.ChainConfig == nil { + return nil + } + return b.ChainConfig.MissingBlockRetry +} + +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() +} + +func ethSyncRpcErrStatus(err error) string { + if stdErrors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + var httpErr rpc.HTTPError + if stdErrors.As(err, &httpErr) { + switch { + case httpErr.StatusCode >= 500: + return "http_5xx" + case httpErr.StatusCode >= 400: + return "http_4xx" + default: + return "http_other" } - }() + } + var rpcErr rpc.Error + if stdErrors.As(err, &rpcErr) { + return "rpc_" + strconv.Itoa(rpcErr.ErrorCode()) + } + return "error" +} - // new mempool transaction notifications handling - // the subscription is done in Initialize - s.chanNewTx = make(chan ethcommon.Hash) - go func() { - for { - t, ok := <-s.chanNewTx - if !ok { - break - } - hex := t.Hex() - if glog.V(2) { - glog.Info("rpc: new tx ", hex) - } - s.Mempool.AddTransactionToMempool(hex) - pushHandler(bchain.NotificationNewTx) +func (b *EthereumRPC) observeEthSyncRpcError(method string, err error) { + if b.metrics == nil || err == nil { + return + } + b.metrics.EthSyncRpcErrors.With(common.Labels{"method": method, "status": ethSyncRpcErrStatus(err)}).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) +} - return s, nil +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 +} + +// dialTimeout bounds the initial RPC/WS handshake. A websocket backend behind a +// load balancer can accept the TCP socket but never complete the upgrade — the +// exact silent stall tipWatchdog exists to heal. Dialing with context.Background() +// then blocks forever, and because reconnectRPC runs on the lone tipWatchdog +// goroutine that single healer parks indefinitely: the cached tip stays frozen, +// resyncIndex keeps reporting a false syncNotNeeded, and sync silently stalls until +// a restart. go-ethereum uses this context only for the first handshake, so the +// established connection's lifetime is unaffected. A var so tests can shorten it. +var dialTimeout = 30 * time.Second + +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)) + } + ctx, cancel := context.WithTimeout(context.Background(), dialTimeout) + defer cancel() + return rpc.DialOptions(ctx, rawURL, opts...) } -func openRPC(url string) (*rpc.Client, *ethclient.Client, error) { - rc, err := rpc.Dial(url) +// 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 } - ec := ethclient.NewClient(rc) + 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 := &DualRPCClient{CallClient: callClient, SubClient: subClient} + ec := &EthereumClient{Client: ethclient.NewClient(callClient)} return rc, ec, nil } // Initialize initializes ethereum rpc interface func (b *EthereumRPC) Initialize() error { - ctx, cancel := context.WithTimeout(context.Background(), b.timeout) + b.OpenRPC = 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 = &EthereumNewBlock{channel: make(chan *types.Header)} + b.NewTx = &EthereumNewTx{channel: make(chan ethcommon.Hash)} + + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() - id, err := b.client.NetworkID(ctx) + id, err := b.Client.NetworkID(ctx) if err != nil { return err } // parameters for getInfo request - switch EthereumNet(id.Uint64()) { + switch Network(id.Uint64()) { case MainNet: b.Testnet = false b.Network = "livenet" - break - case TestNet: + case TestNetSepolia: b.Testnet = true - b.Network = "testnet" - break + 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 { + if err := b.initAlternativeFeeProvider(); err != nil { + return err + } + + // 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, b.metrics) + 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 { @@ -205,13 +746,53 @@ func (b *EthereumRPC) InitializeMempool(addrDescForOutpoint bchain.AddrDescForOu } func (b *EthereumRPC) subscribeEvents() error { - // subscriptions - if err := b.subscribe(func() (*rpc.ClientSubscription, error) { + // The tip notifier, tip watchdog and the NewBlock/NewTx channel readers bind to + // the persistent channels, not to a specific connection, so start them exactly + // once. reconnectRPC -> subscribeEvents then only re-creates the EthSubscribe + // bound subscriptions below, instead of leaking a fresh reader set per reconnect. + b.subscribeReadersOnce.Do(func() { + go b.newBlockNotifier() + go b.tipWatchdog() + // new block notifications handling + go func() { + for { + h, ok := b.NewBlock.Read() + if !ok { + break + } + // Advance the tip from the delivered header, not a re-query over + // the load-balanced HTTP path (see onFeedHeader). + b.onFeedHeader(h) + } + }() + // new mempool transaction notifications handling + if !b.ChainConfig.DisableMempoolSync { + go func() { + for { + t, ok := b.NewTx.Read() + if !ok { + break + } + hex := t.Hex() + if glog.V(2) { + glog.Info("rpc: new tx ", hex) + } + added := b.Mempool.AddTransactionToMempool(hex) + if added { + b.PushHandler(bchain.NotificationNewTx) + } + } + }() + } + }) + + // new block subscription - re-created on every (re)connect + if err := b.subscribe("newHeads", func() (bchain.EVMClientSubscription, error) { // invalidate the previous subscription - it is either the first one or there was an error b.newBlockSubscription = nil - ctx, cancel := context.WithTimeout(context.Background(), b.timeout) + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() - sub, err := b.rpc.EthSubscribe(ctx, b.chanNewBlock, "newHeads") + sub, err := b.RPC.EthSubscribe(ctx, b.NewBlock.Channel(), "newHeads") if err != nil { return nil, errors.Annotatef(err, "EthSubscribe newHeads") } @@ -222,27 +803,38 @@ func (b *EthereumRPC) subscribeEvents() error { return err } - if err := b.subscribe(func() (*rpc.ClientSubscription, 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.chanNewTx, "newPendingTransactions") - if err != nil { - return nil, errors.Annotatef(err, "EthSubscribe newPendingTransactions") + // Arm lastSubNotifyNs at subscribe time, not only on the first tip advance. + // Liveness is otherwise stamped only when a header advances the tip, so a + // subscription that never delivers a usable header leaves it at 0 and keeps + // tipWatchdog's lastNs == 0 gate closed forever: the cached tip never refreshes + // and resyncIndex reports a silent syncNotNeeded. Seeding here lets a stalled + // feed age past the threshold so the watchdog polls and reconnects. + b.markSubscriptionAlive() + + if !b.ChainConfig.DisableMempoolSync { + // new mempool transaction subscription - re-created on every (re)connect + if err := b.subscribe("newPendingTransactions", 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 } // subscribe subscribes notification and tries to resubscribe in case of error -func (b *EthereumRPC) subscribe(f func() (*rpc.ClientSubscription, error)) error { +func (b *EthereumRPC) subscribe(name string, f func() (bchain.EVMClientSubscription, error)) error { s, err := f() if err != nil { return err @@ -256,7 +848,8 @@ func (b *EthereumRPC) subscribe(f func() (*rpc.ClientSubscription, error)) error if e == nil { return } - glog.Error("Subscription error ", e) + glog.Error("Subscription error ", name, ": ", e) + b.ObserveSubscriptionEvent(name, "error") timer := time.NewTimer(time.Second * 2) // try in 2 second interval to resubscribe for { @@ -269,10 +862,12 @@ func (b *EthereumRPC) subscribe(f func() (*rpc.ClientSubscription, error)) error ns, err := f() if err == nil { // subscription successful, restart wait for next error + b.ObserveSubscriptionEvent(name, "resubscribed") s = ns continue Loop } - glog.Error("Resubscribe error ", err) + glog.Error("Resubscribe error ", name, ": ", err) + b.ObserveSubscriptionEvent(name, "resubscribe_failed") timer.Reset(time.Second * 2) } } @@ -281,6 +876,30 @@ func (b *EthereumRPC) subscribe(f func() (*rpc.ClientSubscription, error)) error return nil } +// initAlternativeFeeProvider sets up the configured EVM alternative fee provider. +// When a provider is explicitly selected in the coin config but cannot be +// constructed (for example a required API-key env var such as INFURA_API_KEY is +// missing), the error is returned so startup fails fast rather than silently +// reverting to default fee estimation. +func (b *EthereumRPC) initAlternativeFeeProvider() error { + var err error + if b.ChainConfig.AlternativeEstimateFee == "1inch" { + if b.alternativeFeeProvider, err = NewOneInchFeesProvider(b, b.ChainConfig.AlternativeEstimateFeeParams, b.metrics); err != nil { + b.alternativeFeeProvider = nil + return err + } + } else if b.ChainConfig.AlternativeEstimateFee == "infura" { + if b.alternativeFeeProvider, err = NewInfuraFeesProvider(b, b.ChainConfig.AlternativeEstimateFeeParams, b.metrics); err != nil { + b.alternativeFeeProvider = nil + return err + } + } + if b.alternativeFeeProvider != nil { + glog.Info("Using alternative fee provider ", b.ChainConfig.AlternativeEstimateFee) + } + return nil +} + func (b *EthereumRPC) closeRPC() { if b.newBlockSubscription != nil { b.newBlockSubscription.Unsubscribe() @@ -288,27 +907,37 @@ func (b *EthereumRPC) closeRPC() { if b.newTxSubscription != nil { b.newTxSubscription.Unsubscribe() } - if b.rpc != nil { - b.rpc.Close() + if b.RPC != nil { + b.RPC.Close() } } +// CloseRPC closes the underlying RPC client, aborting any in-flight calls. +// Exported so embedders (e.g. Tron) can abort sync RPCs on shutdown without +// running the EVM-specific subscription/monitor teardown done by Shutdown. +func (b *EthereumRPC) CloseRPC() { + b.closeRPC() +} + func (b *EthereumRPC) reconnectRPC() error { glog.Info("Reconnecting RPC") b.closeRPC() - rc, ec, err := openRPC(b.ChainConfig.RPCURL) + rc, ec, err := b.OpenRPC(b.ChainConfig.RPCURL, b.ChainConfig.RPCURLWS) if err != nil { return err } - b.rpc = rc - b.client = ec + b.RPC = rc + b.Client = ec return b.subscribeEvents() } // Shutdown cleans up rpc interface to ethereum func (b *EthereumRPC) Shutdown(ctx context.Context) error { b.closeRPC() - close(b.chanNewBlock) + b.NewBlock.Close() + b.NewTx.Close() + b.consensusMonitor.shutdown() + b.alternativeSendTxProvider.shutdown() glog.Info("rpc: shutdown") return nil } @@ -329,28 +958,25 @@ func (b *EthereumRPC) GetChainInfo() (*bchain.ChainInfo, error) { if err != nil { return nil, err } - ctx, cancel := context.WithTimeout(context.Background(), b.timeout) + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() - id, err := b.client.NetworkID(ctx) + id, err := b.Client.NetworkID(ctx) if err != nil { return nil, err } - var ver, protocol string - if err := b.rpc.CallContext(ctx, &ver, "web3_clientVersion"); err != nil { - return nil, err - } - if err := b.rpc.CallContext(ctx, &protocol, "eth_protocolVersion"); err != nil { + var ver string + if err := b.RPC.CallContext(ctx, &ver, "web3_clientVersion"); err != nil { return nil, err } rv := &bchain.ChainInfo{ - Blocks: int(h.Number.Int64()), - Bestblockhash: h.Hash().Hex(), - Difficulty: h.Difficulty.String(), - Version: ver, - ProtocolVersion: protocol, + Blocks: int(h.Number().Int64()), + Bestblockhash: h.Hash(), + Difficulty: h.Difficulty().String(), + Version: ver, + ConsensusVersion: b.consensusMonitor.get(), } idi := int(id.Uint64()) - if idi == 1 { + if idi == int(b.MainNetChainID) { rv.Chain = "mainnet" } else { rv.Chain = "testnet " + strconv.Itoa(idi) @@ -358,39 +984,281 @@ func (b *EthereumRPC) GetChainInfo() (*bchain.ChainInfo, error) { return rv, nil } -func (b *EthereumRPC) getBestHeader() (*ethtypes.Header, error) { +func (b *EthereumRPC) getBestHeader() (bchain.EVMHeader, error) { b.bestHeaderLock.Lock() defer b.bestHeaderLock.Unlock() - // if the best header was not updated for 15 minutes, there could be a subscription problem, reconnect RPC - // do it only in case of normal operation, not initial synchronization - if b.bestHeaderTime.Add(15*time.Minute).Before(time.Now()) && !b.bestHeaderTime.IsZero() && b.mempoolInitialized { - err := b.reconnectRPC() - if err != nil { - return nil, err - } - b.bestHeader = nil - } + // Subscription liveness (detecting a silently stalled newHeads feed and + // reconnecting) is owned by tipWatchdog, which runs off the bestHeaderLock so a + // reconnect can no longer block every concurrent tip reader. Here we only lazily + // fetch the very first header; afterwards the cache is advanced by the + // subscription-driven newBlockNotifier and by the watchdog's fallback poll. if b.bestHeader == nil { var err error - ctx, cancel := context.WithTimeout(context.Background(), b.timeout) + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() - b.bestHeader, err = b.client.HeaderByNumber(ctx, nil) + b.bestHeader, err = b.Client.HeaderByNumber(ctx, nil) if err != nil { b.bestHeader = nil return nil, err } - b.bestHeaderTime = time.Now() } return b.bestHeader, nil } +// UpdateBestHeader keeps track of the latest block header confirmed on chain. +// Non-monotonic: callers (Tron's ZeroMQ feed) own their ordering/reorg handling. +func (b *EthereumRPC) UpdateBestHeader(h bchain.EVMHeader) { + if h == nil || h.Number() == nil { + return + } + glog.V(2).Info("rpc: new block header ", h.Number().Uint64()) + b.setBestHeader(h, false) +} + +func (b *EthereumRPC) signalNewBlock() { + // Non-blocking send: one pending signal is enough to wake the sync loop. + select { + case b.newBlockNotifyCh <- struct{}{}: + default: + } +} + +// onFeedHeader advances the cached tip from the header the newHeads feed just +// delivered (not a re-query over HTTP) and, only on a real advance, refreshes +// liveness and wakes the sync loop. Behind a load balancer an HTTP re-query can +// hit a lagging node and report a stale tip, freezing sync into a false "synced" +// while newHeads still flows; the feed's header is authoritative. The update is +// monotonic so a resubscribe onto a behind node cannot regress the tip. +func (b *EthereumRPC) onFeedHeader(h bchain.EVMHeader) { + if b.setBestHeader(h, true) { + b.markSubscriptionAlive() + b.signalNewBlock() + } +} + +// newBlockNotifier wakes the sync loop after onFeedHeader advanced the tip. It is +// decoupled from the reader via newBlockNotifyCh so a slow PushHandler cannot +// stall the reader and back the newHeads channel up. +func (b *EthereumRPC) newBlockNotifier() { + for range b.newBlockNotifyCh { + b.PushHandler(bchain.NotificationNewBlock) + } +} + +const ( + // tipWatchdogStaleBlocks scales the silent-stall window to the chain's cadence: + // if no newHeads notification arrives for this many nominal block intervals, the + // subscription is presumed dead and the watchdog heals it. Behind a load + // balancer a newHeads feed can stop delivering with no error on sub.Err(), so a + // purely error-driven resubscribe never fires. + tipWatchdogStaleBlocks = 30 + // tipWatchdogMinStale / tipWatchdogMaxStale clamp the derived window so fast + // chains do not react to routine jitter and slow/misconfigured chains still + // recover in bounded time (the previous behaviour was a fixed 15 minutes, which + // on Polygon's 2s blocks meant ~450 missed blocks before any reaction). + tipWatchdogMinStale = 30 * time.Second + tipWatchdogMaxStale = 5 * time.Minute + // tipWatchdogMinInterval / tipWatchdogMaxInterval bound the sampling cadence. + tipWatchdogMinInterval = 5 * time.Second + tipWatchdogMaxInterval = 60 * time.Second +) + +// ObserveSubscriptionEvent records a push-subscription lifecycle event. Exported +// so embedders with their own notification feed (e.g. Tron's ZeroMQ) emit the +// same metric. +func (b *EthereumRPC) ObserveSubscriptionEvent(subscription, event string) { + if b.metrics == nil { + return + } + b.metrics.BackendSubscriptionEvents.With(common.Labels{"subscription": subscription, "event": event}).Inc() +} + +// SetSubscriptionAgeSeconds records the age of the newest notification from the +// tip feed. Exported for embedders that run their own watchdog. +func (b *EthereumRPC) SetSubscriptionAgeSeconds(seconds float64) { + if b.metrics == nil { + return + } + b.metrics.BackendSubscriptionAgeSeconds.Set(seconds) +} + +// markSubscriptionAlive records that the feed just advanced the cached tip — the +// signal tipWatchdog uses to tell a live, progressing feed from one that went +// silent or got stuck on a single height. +func (b *EthereumRPC) markSubscriptionAlive() { + b.lastSubNotifyNs.Store(time.Now().UnixNano()) +} + +// TipStaleThreshold derives the silent-feed window from the chain's average block +// time, clamped to a sane range. Exported so embedders (Tron, Avalanche) size +// their watchdog window with the same policy. +func (b *EthereumRPC) TipStaleThreshold() time.Duration { + avg := time.Duration(b.ChainConfig.AverageBlockTimeMs) * time.Millisecond + if avg <= 0 { + return tipWatchdogMaxStale + } + d := tipWatchdogStaleBlocks * avg + if d < tipWatchdogMinStale { + return tipWatchdogMinStale + } + if d > tipWatchdogMaxStale { + return tipWatchdogMaxStale + } + return d +} + +// tipWatchdog detects a newHeads subscription that has silently stopped +// delivering (common behind load balancers, which can drop the upstream without +// signalling sub.Err()). On a stall it first polls the tip directly so sync keeps +// progressing instead of trusting a frozen cached tip as "synced", then reconnects +// to restore push delivery. It is started exactly once via subscribeReadersOnce. +func (b *EthereumRPC) tipWatchdog() { + threshold := b.TipStaleThreshold() + interval := threshold / 3 + if interval < tipWatchdogMinInterval { + interval = tipWatchdogMinInterval + } + if interval > tipWatchdogMaxInterval { + interval = tipWatchdogMaxInterval + } + glog.Infof("rpc: tip watchdog started, stall threshold %s, sampling every %s", threshold, interval) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + if common.IsInShutdown() { + return + } + b.tipWatchdogTick(threshold) + } +} + +// tipWatchdogTick is one watchdog evaluation, split out from the ticker loop so +// it is unit-testable with an injected threshold and a fake client (no 30s wait). +func (b *EthereumRPC) tipWatchdogTick(threshold time.Duration) { + // Heartbeat first: this Inc proves the lone watchdog goroutine is still ticking. + // If it ever parks (e.g. a hung reconnect), the counter stops and + // rate(blockbook_backend_subscription_events{event="watchdog_tick"}) drops to 0 — + // the only positive liveness signal for the sole feed-liveness healer, distinct + // from watchdog_stall/watchdog_reconnect which fire only on an already-seen stall. + b.ObserveSubscriptionEvent("newHeads", "watchdog_tick") + // lastSubNotifyNs is armed when subscribeEvents establishes the newHeads + // subscription and refreshed on every tip advance, so a non-zero value means + // "subscription is wired up". The zero guard only skips the brief window before + // the first subscribe (i.e. before InitializeMempool runs); it must not be the + // sole arming signal, or a feed that never advances would keep the watchdog off. + lastNs := b.lastSubNotifyNs.Load() + if lastNs == 0 { + return + } + age := time.Since(time.Unix(0, lastNs)) + b.SetSubscriptionAgeSeconds(age.Seconds()) + if age < threshold { + return + } + glog.Warningf("rpc: newHeads subscription silent for %s (threshold %s); polling tip and reconnecting", age.Truncate(time.Second), threshold) + b.ObserveSubscriptionEvent("newHeads", "watchdog_stall") + // Keep sync alive immediately: poll the canonical tip directly so a dead push + // channel can no longer freeze the cached tip into a false "synced". The poll is + // allowed to regress the tip here (unlike the hot path): after a sustained stall + // a lower backend height is a real rollback, not the transient load-balancer lag + // the monotonic guard filters (that lag resolves well within the stall window). + // Without this, a genuine rollback would leave the cached tip pinned above the + // backend and equal to the local DB tip, so resyncIndex keeps early-exiting as + // "synced" and never reaches its fork path (db/sync.go GetBlockHash check). + prevHeight := b.cachedTipHeight() + if updated, err := b.refreshBestHeaderFromChain(true); err != nil { + glog.Error("rpc: tip watchdog tip poll error ", err) + } else if updated { + if newHeight := b.cachedTipHeight(); newHeight < prevHeight { + glog.Warningf("rpc: tip watchdog observed backend rollback, cached tip %d -> %d; letting sync reconcile the fork", prevHeight, newHeight) + b.ObserveSubscriptionEvent("newHeads", "watchdog_tip_rollback") + } else { + b.ObserveSubscriptionEvent("newHeads", "watchdog_tip_advanced") + } + b.PushHandler(bchain.NotificationNewBlock) + } + // Restore push delivery by reconnecting the RPC and re-subscribing. + if err := b.reconnectRPC(); err != nil { + glog.Error("rpc: tip watchdog reconnect error ", err) + b.ObserveSubscriptionEvent("rpc", "watchdog_reconnect_failed") + return + } + b.ObserveSubscriptionEvent("rpc", "watchdog_reconnect") + // Give the fresh subscription a full window before judging it again so a + // flapping backend cannot trigger a reconnect storm. + b.markSubscriptionAlive() +} + +// refreshBestHeaderFromChain polls the tip over HTTP. It is the watchdog's +// fallback when the push feed is silent (no longer on the hot path). +// +// allowRegress controls the monotonic guard. Callers on the hot path pass false so +// a lagging load-balancer node cannot regress the tip and trip a spurious fork. The +// watchdog passes true: it only polls after a sustained stall (TipStaleThreshold), +// by which point transient routing lag has resolved, so a still-lower backend tip +// is a genuine rollback the cached tip must follow down — otherwise the guard pins +// the tip above the backend and resyncIndex keeps reporting a false "synced". +func (b *EthereumRPC) refreshBestHeaderFromChain(allowRegress bool) (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, !allowRegress), nil +} + +// setBestHeader stores h as the cached tip and reports whether it changed (new +// height, or same-height hash change i.e. a tip reorg). When monotonic, a lower +// height is rejected so a lagging load-balancer node cannot regress the tip and +// trip a spurious fork. A sustained real rollback (the backend genuinely below the +// cached tip past TipStaleThreshold) is instead recovered by tipWatchdog, which +// re-polls with the guard lifted so the tip follows the backend down and resyncIndex +// reaches its fork path; see refreshBestHeaderFromChain. +func (b *EthereumRPC) setBestHeader(h bchain.EVMHeader, monotonic bool) bool { + if h == nil || h.Number() == nil { + return false + } + b.bestHeaderLock.Lock() + defer b.bestHeaderLock.Unlock() + if b.bestHeader != nil && b.bestHeader.Number() != nil { + prevNum := b.bestHeader.Number().Uint64() + newNum := h.Number().Uint64() + if newNum == prevNum && b.bestHeader.Hash() == h.Hash() { + return false // identical tip: not progress + } + if monotonic && newNum < prevNum { + return false // lagging node: keep the higher tip + } + } + b.bestHeader = h + return true +} + +// cachedTipHeight returns the height of the cached tip, or 0 if it is unset. The +// watchdog uses it to tell a forward advance from a rollback for logging/metrics. +func (b *EthereumRPC) cachedTipHeight() uint64 { + b.bestHeaderLock.Lock() + defer b.bestHeaderLock.Unlock() + if b.bestHeader == nil || b.bestHeader.Number() == nil { + return 0 + } + return b.bestHeader.Number().Uint64() +} + // GetBestBlockHash returns hash of the tip of the best-block-chain func (b *EthereumRPC) GetBestBlockHash() (string, error) { h, err := b.getBestHeader() if err != nil { return "", err } - return h.Hash().Hex(), nil + return h.Hash(), nil } // GetBestBlockHeight returns height of the tip of the best-block-chain @@ -399,23 +1267,23 @@ func (b *EthereumRPC) GetBestBlockHeight() (uint32, error) { if err != nil { return 0, err } - return uint32(h.Number.Uint64()), nil + return uint32(h.Number().Uint64()), nil } // GetBlockHash returns hash of block in best-block-chain at given height func (b *EthereumRPC) GetBlockHash(height uint32) (string, error) { var n big.Int n.SetUint64(uint64(height)) - ctx, cancel := context.WithTimeout(context.Background(), b.timeout) + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() - h, err := b.client.HeaderByNumber(ctx, &n) + 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) } - return h.Hash().Hex(), nil + return h.Hash(), nil } func (b *EthereumRPC) ethHeaderToBlockHeader(h *rpcHeader) (*bchain.BlockHeader, error) { @@ -449,7 +1317,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 { @@ -463,51 +1331,220 @@ func (b *EthereumRPC) computeConfirmations(n uint64) (uint32, error) { if err != nil { return 0, err } - bn := bh.Number.Uint64() + bn := bh.Number().Uint64() // transaction in the best block has 1 confirmation return uint32(bn - n + 1), nil } func (b *EthereumRPC) getBlockRaw(hash string, height uint32, fullTxs bool) (json.RawMessage, error) { - ctx, cancel := context.WithTimeout(context.Background(), b.timeout) + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() var raw json.RawMessage var err error + var method string if hash != "" { if hash == "pending" { - err = b.rpc.CallContext(ctx, &raw, "eth_getBlockByNumber", hash, fullTxs) + method = "eth_getBlockByNumber" + err = b.RPC.CallContext(ctx, &raw, method, hash, fullTxs) } else { - err = b.rpc.CallContext(ctx, &raw, "eth_getBlockByHash", ethcommon.HexToHash(hash), fullTxs) + method = "eth_getBlockByHash" + err = b.RPC.CallContext(ctx, &raw, method, ethcommon.HexToHash(hash), fullTxs) } } else { - err = b.rpc.CallContext(ctx, &raw, "eth_getBlockByNumber", fmt.Sprintf("%#x", height), fullTxs) + method = "eth_getBlockByNumber" + err = b.RPC.CallContext(ctx, &raw, method, fmt.Sprintf("%#x", height), fullTxs) } + b.observeEthSyncRpcError(method, err) 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 } -func (b *EthereumRPC) getERC20EventsForBlock(blockNumber string) (map[string][]*rpcLog, error) { - ctx, cancel := context.WithTimeout(context.Background(), b.timeout) +// 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() var logs []rpcLogWithTxHash - err := b.rpc.CallContext(ctx, &logs, "eth_getLogs", map[string]interface{}{ + var ensRecords []bchain.AddressAliasRecord + var method = "eth_getLogs" + err := b.RPC.CallContext(ctx, &logs, method, map[string]interface{}{ "fromBlock": blockNumber, "toBlock": blockNumber, - "topics": []string{erc20TransferEventSignature}, }) + b.observeEthSyncRpcError(method, err) if err != nil { - return nil, errors.Annotatef(err, "blockNumber %v", blockNumber) + return nil, nil, errors.Annotatef(err, "%s blockNumber %v", method, blockNumber) } - r := make(map[string][]*rpcLog) + r := make(map[string][]*bchain.RpcLog) for i := range logs { l := &logs[i] - r[l.Hash] = append(r[l.Hash], &l.rpcLog) + r[l.Hash] = append(r[l.Hash], &l.RpcLog) + ens := getEnsRecord(l) + if ens != nil { + ensRecords = append(ensRecords, *ens) + } } - return r, nil + return r, ensRecords, nil +} + +type rpcCallTrace struct { + // CREATE, CREATE2, SELFDESTRUCT, CALL, CALLCODE, DELEGATECALL, STATICCALL + Type string `json:"type"` + From string `json:"from"` + To string `json:"to"` + Value string `json:"value"` + Error string `json:"error"` + Output string `json:"output"` + Calls []rpcCallTrace `json:"calls"` +} + +type rpcTraceResult struct { + Result rpcCallTrace `json:"result"` +} + +func (b *EthereumRPC) getCreationContractInfo(contract string, height uint32) *bchain.ContractInfo { + // 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, + Value: *value, + From: call.From, + To: call.To, // new contract address + }) + contracts = append(contracts, *b.getCreationContractInfo(call.To, blockHeight)) + } else if call.Type == "SELFDESTRUCT" { + d.Transfers = append(d.Transfers, bchain.EthereumInternalTransfer{ + Type: bchain.SELFDESTRUCT, + Value: *value, + From: call.From, // destroyed contract address + To: call.To, + }) + contracts = append(contracts, bchain.ContractInfo{Contract: call.From, DestructedInBlock: blockHeight}) + } else if call.Type == "DELEGATECALL" { + // ignore DELEGATECALL (geth v1.11 the changed tracer behavior) + // https://github.com/ethereum/go-ethereum/issues/26726 + } else if err == nil && (value.BitLen() > 0 || b.ChainConfig.ProcessZeroInternalTransactions) { + d.Transfers = append(d.Transfers, bchain.EthereumInternalTransfer{ + Value: *value, + From: call.From, + To: call.To, + }) + } + if call.Error != "" { + d.Error = call.Error + } + for i := range call.Calls { + contracts = b.processCallTrace(&call.Calls[i], d, contracts, blockHeight) + } + return contracts +} + +// 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 bchain.ProcessInternalTransactions { + var trace []rpcTraceResult + 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. + b.observeEthSyncRpcError("debug_traceBlockByHash", err) + if err != nil { + glog.Error("debug_traceBlockByHash block ", blockHash, ", error ", err) + return data, contracts, err + } + if len(trace) != len(data) { + 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 + d := &data[i] + if r.Type == "CREATE" || r.Type == "CREATE2" { + d.Type = bchain.CREATE + d.Contract = r.To + contracts = append(contracts, *b.getCreationContractInfo(d.Contract, blockHeight)) + } else if r.Type == "SELFDESTRUCT" { + d.Type = bchain.SELFDESTRUCT + } + for j := range r.Calls { + contracts = b.processCallTrace(&r.Calls[j], d, contracts, blockHeight) + } + if r.Error != "" { + baseError := PackInternalTransactionError(r.Error) + if len(baseError) > 1 { + // n, _ := ethNumber(transactions[i].BlockNumber) + // glog.Infof("Internal Data Error %d %s: unknown base error %s", n, transactions[i].Hash, baseError) + baseError = strings.ToUpper(baseError[:1]) + baseError[1:] + ". " + } + outputError := ParseErrorFromOutput(r.Output) + if len(outputError) > 0 { + d.Error = baseError + strings.ToUpper(outputError[:1]) + outputError[1:] + } else { + traceError := PackInternalTransactionError(d.Error) + if traceError == baseError { + d.Error = baseError + } else { + d.Error = baseError + traceError + } + } + // n, _ := ethNumber(transactions[i].BlockNumber) + // glog.Infof("Internal Data Error %d %s: %s", n, transactions[i].Hash, UnpackInternalTransactionError([]byte(d.Error))) + } + } + } + return data, contracts, nil } // GetBlock returns block with given hash or height, hash has precedence if both passed @@ -516,38 +1553,87 @@ 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 ERC20 events - logs, err := b.getERC20EventsForBlock(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 + // pass internalData error and ENS records in blockSpecificData to be stored + if internalErr != nil || len(ens) > 0 || len(contracts) > 0 { + blockSpecificData = &bchain.EthereumBlockSpecificData{} + if internalErr != nil { + blockSpecificData.InternalDataError = internalErr.Error() + // glog.Info("InternalDataError ", bbh.Height, ": ", internalErr.Error()) + } + if len(ens) > 0 { + blockSpecificData.AddressAliasRecords = ens + // glog.Info("ENS", ens) + } + if len(contracts) > 0 { + blockSpecificData.Contracts = contracts + // glog.Info("Contracts", contracts) + } } + btxs := make([]bchain.Tx, len(body.Transactions)) for i := range body.Transactions { tx := &body.Transactions[i] - btx, err := b.Parser.ethTxToTx(tx, &rpcReceipt{Logs: logs[tx.Hash]}, 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, - Txs: btxs, + BlockHeader: *bbh, + Txs: btxs, + CoinSpecificData: blockSpecificData, } return &bbk, nil } @@ -584,25 +1670,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) + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() - var tx *rpcTransaction + 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, 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) } @@ -613,7 +1717,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) @@ -622,8 +1727,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 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) } @@ -635,27 +1740,24 @@ 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, 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 } // GetTransactionSpecific returns json as returned by backend, with all coin specific data func (b *EthereumRPC) GetTransactionSpecific(tx *bchain.Tx) (json.RawMessage, error) { - csd, ok := tx.CoinSpecificData.(completeTransaction) + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) if !ok { ntx, err := b.GetTransaction(tx.Txid) if err != nil { return nil, err } - csd, ok = ntx.CoinSpecificData.(completeTransaction) + csd, ok = ntx.CoinSpecificData.(bchain.EthereumSpecificData) if !ok { return nil, errors.New("Cannot get CoinSpecificData") } @@ -686,17 +1788,19 @@ func (b *EthereumRPC) EstimateFee(blocks int) (big.Int, error) { // EstimateSmartFee returns fee estimation func (b *EthereumRPC) EstimateSmartFee(blocks int, conservative bool) (big.Int, error) { - ctx, cancel := context.WithTimeout(context.Background(), b.timeout) + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() var r big.Int - gp, err := b.client.SuggestGasPrice(ctx) + gp, err := b.Client.SuggestGasPrice(ctx) if err == nil && b != nil { r = *gp } return r, err } -func getStringFromMap(p string, params map[string]interface{}) (string, bool) { +// GetStringFromMap attempts to return the value for a specific key in a map as a string if valid, +// otherwise returns an empty string with false indicating there was no key found, or the value was not a string +func GetStringFromMap(p string, params map[string]interface{}) (string, bool) { v, ok := params[p] if ok { s, ok := v.(string) @@ -707,73 +1811,484 @@ func getStringFromMap(p string, params map[string]interface{}) (string, bool) { // EthereumTypeEstimateGas returns estimation of gas consumption for given transaction parameters func (b *EthereumRPC) EthereumTypeEstimateGas(params map[string]interface{}) (uint64, error) { - ctx, cancel := context.WithTimeout(context.Background(), b.timeout) + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() msg := ethereum.CallMsg{} - s, ok := getStringFromMap("from", params) - if ok && len(s) > 0 { + if s, ok := GetStringFromMap("from", params); ok && len(s) > 0 { msg.From = ethcommon.HexToAddress(s) } - s, ok = getStringFromMap("to", params) - if ok && len(s) > 0 { + if s, ok := GetStringFromMap("to", params); ok && len(s) > 0 { a := ethcommon.HexToAddress(s) msg.To = &a } - s, ok = getStringFromMap("data", params) - if ok && len(s) > 0 { + if s, ok := GetStringFromMap("data", params); ok && len(s) > 0 { msg.Data = ethcommon.FromHex(s) } - s, ok = getStringFromMap("value", params) - if ok && len(s) > 0 { + if s, ok := GetStringFromMap("value", params); ok && len(s) > 0 { msg.Value, _ = hexutil.DecodeBig(s) } - s, ok = getStringFromMap("gas", params) - if ok && len(s) > 0 { + if s, ok := GetStringFromMap("gas", params); ok && len(s) > 0 { msg.Gas, _ = hexutil.DecodeUint64(s) } - s, ok = getStringFromMap("gasPrice", params) - if ok && len(s) > 0 { + if s, ok := GetStringFromMap("gasPrice", params); ok && len(s) > 0 { msg.GasPrice, _ = hexutil.DecodeBig(s) } - return b.client.EstimateGas(ctx, msg) + + 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 { + fees, err := b.alternativeFeeProvider.GetEip1559Fees() + if err != nil { + return nil, err + } + if fees != nil { + return fees, nil + } + // Fall back to on-chain estimation when the alternative provider is unsupported/stale/unready, + // so configured networks still return EIP-1559 fees instead of nil, which resolves to empty fees. + } + + // 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) { - ctx, cancel := context.WithTimeout(context.Background(), b.timeout) +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) + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() - return b.client.BalanceAt(ctx, ethcommon.BytesToAddress(addrDesc), nil) + return b.Client.BalanceAt(ctx, addrDesc, nil) } -// EthereumTypeGetNonce returns current balance of an address -func (b *EthereumRPC) EthereumTypeGetNonce(addrDesc bchain.AddressDescriptor) (uint64, error) { - ctx, cancel := context.WithTimeout(context.Background(), b.timeout) +// EthereumTypeGetNonces returns the pending account nonce and, only when withConfirmed +// is set, the confirmed (latest) nonce. +// +// The pending nonce (eth_getTransactionCount at the "pending" tag) counts transactions +// still queued in the mempool and is the next nonce the account will use; it is always +// fetched and is required, so a failure to obtain it returns an error. The confirmed nonce +// (the "latest" tag) reflects only mined transactions and requires a second backend call, +// so it is gated behind withConfirmed to avoid that cost on every address request. When +// requested, both tags are fetched in a single JSON-RPC batch round-trip so the confirmed +// value adds no extra latency. The confirmed nonce is best-effort: if only the latest +// lookup fails, the pending nonce is still returned with confirmedOK=false so the caller +// can omit it rather than failing the whole request. When confirmedOK is false the returned +// confirmed value is 0 and must be ignored. +func (b *EthereumRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool) (uint64, uint64, bool, error) { + ethAddress := ethcommon.BytesToAddress(addrDesc) + + if b.alternativeSendTxProvider != nil { + pending, confirmed, confirmedOK, err := b.alternativeSendTxProvider.getNonces(ethAddress, withConfirmed) + if err == nil { + return pending, confirmed, confirmedOK, nil + } + glog.Errorf("Alternative provider failed for eth_getTransactionCount: %v, falling back to primary RPC", err) + } + + pending, confirmed, confirmedOK, err := b.getNoncesRPC(ethAddress, withConfirmed) + if err != nil { + glog.Errorf("Primary RPC failed for eth_getTransactionCount: %v", err) + return 0, 0, false, err + } + return pending, confirmed, confirmedOK, nil +} + +// getNoncesRPC fetches the pending account nonce from the primary RPC, plus the confirmed +// (latest) nonce when withConfirmed is set. When both are requested and the client supports +// JSON-RPC batching, they are fetched in a single round-trip; otherwise the calls are made +// sequentially (e.g. a minimal RPC mock in tests). The confirmed nonce is best-effort (see +// EthereumTypeGetNonces): a failed latest lookup yields confirmedOK=false, not an error. +func (b *EthereumRPC) getNoncesRPC(addr ethcommon.Address, withConfirmed bool) (uint64, uint64, bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.Timeout) defer cancel() - return b.client.NonceAt(ctx, ethcommon.BytesToAddress(addrDesc), nil) + + if !withConfirmed { + pending, err := b.getTransactionCount(ctx, addr, "pending") + if err != nil { + return 0, 0, false, err + } + return pending, 0, false, nil + } + + if bc, ok := b.RPC.(interface { + BatchCallContext(context.Context, []rpc.BatchElem) error + }); ok { + var pendingHex, confirmedHex string + batch := []rpc.BatchElem{ + {Method: "eth_getTransactionCount", Args: []interface{}{addr, "pending"}, Result: &pendingHex}, + {Method: "eth_getTransactionCount", Args: []interface{}{addr, "latest"}, Result: &confirmedHex}, + } + if err := bc.BatchCallContext(ctx, batch); err != nil { + return 0, 0, false, err + } + if batch[0].Error != nil { + return 0, 0, false, batch[0].Error + } + pending, err := hexutil.DecodeUint64(pendingHex) + if err != nil { + return 0, 0, false, errors.Annotatef(err, "pending nonce %q", pendingHex) + } + confirmed, confirmedOK := decodeConfirmedNonce(addr, confirmedHex, batch[1].Error) + return pending, confirmed, confirmedOK, nil + } + + pending, err := b.getTransactionCount(ctx, addr, "pending") + if err != nil { + return 0, 0, false, err + } + var confirmedHex string + cerr := b.RPC.CallContext(ctx, &confirmedHex, "eth_getTransactionCount", addr, "latest") + confirmed, confirmedOK := decodeConfirmedNonce(addr, confirmedHex, cerr) + return pending, confirmed, confirmedOK, nil +} + +// getTransactionCount fetches and decodes a single eth_getTransactionCount value at the given +// block tag. +func (b *EthereumRPC) getTransactionCount(ctx context.Context, addr ethcommon.Address, tag string) (uint64, error) { + var hex string + if err := b.RPC.CallContext(ctx, &hex, "eth_getTransactionCount", addr, tag); err != nil { + return 0, err + } + n, err := hexutil.DecodeUint64(hex) + if err != nil { + return 0, errors.Annotatef(err, "%s nonce %q", tag, hex) + } + return n, nil +} + +// decodeConfirmedNonce decodes the best-effort confirmed (latest) nonce. On any error (lookup +// or decode) it logs and reports confirmedOK=false so the caller omits the confirmed nonce +// instead of failing the request. +func decodeConfirmedNonce(addr ethcommon.Address, confirmedHex string, lookupErr error) (uint64, bool) { + if lookupErr != nil { + glog.Warningf("confirmed nonce (latest) lookup failed for %s: %v; omitting confirmedNonce", addr.Hex(), lookupErr) + return 0, false + } + confirmed, err := hexutil.DecodeUint64(confirmedHex) + if err != nil { + glog.Warningf("confirmed nonce (latest) decode failed for %s (%q): %v; omitting confirmedNonce", addr.Hex(), confirmedHex, err) + return 0, false + } + return confirmed, true } // 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_tip_watchdog_test.go b/bchain/coins/eth/ethrpc_tip_watchdog_test.go new file mode 100644 index 0000000000..fe1e54ac56 --- /dev/null +++ b/bchain/coins/eth/ethrpc_tip_watchdog_test.go @@ -0,0 +1,359 @@ +package eth + +import ( + "context" + "errors" + "io" + "math/big" + "net" + "testing" + "time" + + "github.com/trezor/blockbook/bchain" +) + +// TipStaleThreshold scales the silent-subscription window to the chain's block +// cadence (replacing the old fixed 15m), clamped so fast chains don't react to +// jitter and slow chains still recover in bounded time. +func TestTipStaleThreshold(t *testing.T) { + tests := []struct { + name string + averageBlockTime int + want time.Duration + }{ + {name: "polygon 2s -> 30 blocks", averageBlockTime: 2000, want: 60 * time.Second}, + {name: "bsc 3s -> 30 blocks", averageBlockTime: 3000, want: 90 * time.Second}, + {name: "ethereum 12s clamped to max", averageBlockTime: 12000, want: tipWatchdogMaxStale}, + {name: "10s lands exactly on max", averageBlockTime: 10000, want: tipWatchdogMaxStale}, + {name: "arbitrum 250ms clamped to min", averageBlockTime: 250, want: tipWatchdogMinStale}, + {name: "unset falls back to max", averageBlockTime: 0, want: tipWatchdogMaxStale}, + {name: "negative falls back to max", averageBlockTime: -1, want: tipWatchdogMaxStale}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &EthereumRPC{ChainConfig: &Configuration{AverageBlockTimeMs: tt.averageBlockTime}} + if got := b.TipStaleThreshold(); got != tt.want { + t.Fatalf("TipStaleThreshold() = %s, want %s", got, tt.want) + } + }) + } +} + +func TestMarkSubscriptionAlive(t *testing.T) { + b := &EthereumRPC{} + if got := b.lastSubNotifyNs.Load(); got != 0 { + t.Fatalf("lastSubNotifyNs should start at 0, got %d", got) + } + before := time.Now().UnixNano() + b.markSubscriptionAlive() + if got := b.lastSubNotifyNs.Load(); got < before { + t.Fatalf("markSubscriptionAlive() recorded %d, want >= %d", got, before) + } +} + +// --- minimal fakes implementing only what the watchdog touches --- + +type stubHeader struct { + n int64 + h string // optional hash override; defaults to a value derived from n +} + +func (h stubHeader) Hash() string { + if h.h != "" { + return h.h + } + return string(rune(h.n)) +} +func (h stubHeader) Number() *big.Int { return big.NewInt(h.n) } +func (h stubHeader) Difficulty() *big.Int { return big.NewInt(0) } + +type stubHeaderClient struct { + bchain.EVMClient // embed for the methods the watchdog never calls + height int64 +} + +func (c *stubHeaderClient) HeaderByNumber(context.Context, *big.Int) (bchain.EVMHeader, error) { + return stubHeader{n: c.height}, nil +} + +// On a stale feed the watchdog must poll the tip, push a new-block notification, +// and attempt a reconnect — exercised here without waiting on the real ticker. +// Reconnect runs after the poll/push, so we let OpenRPC fail (closeRPC is nil-safe) +// to assert it was attempted without standing up subscription plumbing whose only +// job would be to echo success back. +func TestEthereumTipWatchdogTickOnStaleFeed(t *testing.T) { + pushes := make(chan bchain.NotificationType, 4) + reconnectAttempted := false + b := &EthereumRPC{ + ChainConfig: &Configuration{AverageBlockTimeMs: 2000}, + Timeout: time.Second, + PushHandler: func(nt bchain.NotificationType) { pushes <- nt }, + } + b.Client = &stubHeaderClient{height: 100} + b.OpenRPC = func(string, string) (bchain.EVMRPCClient, bchain.EVMClient, error) { + reconnectAttempted = true + return nil, nil, errors.New("reconnect disabled in test") + } + // Simulate a silently stalled subscription: last notification long ago. + b.lastSubNotifyNs.Store(time.Now().Add(-time.Hour).UnixNano()) + + b.tipWatchdogTick(time.Millisecond) + + select { + case nt := <-pushes: + if nt != bchain.NotificationNewBlock { + t.Fatalf("pushed %v, want NotificationNewBlock", nt) + } + default: + t.Fatal("watchdog did not push NotificationNewBlock on a stale feed") + } + if !reconnectAttempted { + t.Fatal("watchdog did not attempt reconnect on a stale feed") + } +} + +// On a sustained stall the watchdog must let a genuinely lower backend tip regress +// the cached tip. The hot-path monotonic guard rejects a lower height to ride out +// transient load-balancer lag, but that lag resolves well within TipStaleThreshold; +// a tip still below ours after the stall window is a real rollback. If the cached +// tip stayed frozen above the backend it would equal the local DB tip, so +// resyncIndex would keep early-exiting as "synced" (localBestHash == cached +// remoteBestHash) and never reach its GetBlockHash fork path. +func TestEthereumTipWatchdogRegressesTipOnRollback(t *testing.T) { + pushes := make(chan bchain.NotificationType, 4) + reconnectAttempted := false + b := &EthereumRPC{ + ChainConfig: &Configuration{AverageBlockTimeMs: 2000}, + Timeout: time.Second, + PushHandler: func(nt bchain.NotificationType) { pushes <- nt }, + } + // Cached tip is ahead at 200 (where the feed froze before the backend rolled back). + if !b.setBestHeader(stubHeader{n: 200}, true) { + t.Fatal("precondition: setting initial tip 200 failed") + } + // The backend now reports a lower tip (a real rollback to height 150). + b.Client = &stubHeaderClient{height: 150} + b.OpenRPC = func(string, string) (bchain.EVMRPCClient, bchain.EVMClient, error) { + reconnectAttempted = true + return nil, nil, errors.New("reconnect disabled in test") + } + b.lastSubNotifyNs.Store(time.Now().Add(-time.Hour).UnixNano()) + + b.tipWatchdogTick(time.Millisecond) + + if h, err := b.getBestHeader(); err != nil { + t.Fatal(err) + } else if got := h.Number().Int64(); got != 150 { + t.Fatalf("cached tip = %d, want 150 (regressed to the rolled-back backend tip so resyncIndex can detect the fork)", got) + } + select { + case nt := <-pushes: + if nt != bchain.NotificationNewBlock { + t.Fatalf("pushed %v, want NotificationNewBlock", nt) + } + default: + t.Fatal("watchdog did not push NotificationNewBlock after a rollback") + } + if !reconnectAttempted { + t.Fatal("watchdog did not attempt reconnect after a rollback") + } +} + +// A fresh feed (recent notification) must not poll or reconnect. +func TestEthereumTipWatchdogTickFreshFeedNoop(t *testing.T) { + pushes := make(chan bchain.NotificationType, 1) + b := &EthereumRPC{ + ChainConfig: &Configuration{AverageBlockTimeMs: 2000}, + PushHandler: func(nt bchain.NotificationType) { pushes <- nt }, + } + b.OpenRPC = func(string, string) (bchain.EVMRPCClient, bchain.EVMClient, error) { + t.Fatal("watchdog reconnected on a fresh feed") + return nil, nil, nil + } + b.lastSubNotifyNs.Store(time.Now().UnixNano()) + + b.tipWatchdogTick(time.Minute) + + if len(pushes) != 0 { + t.Fatal("watchdog pushed on a fresh feed") + } +} + +// The feed's own header must drive the cached tip even when HTTP (HeaderByNumber) +// is pinned to a lagging height, so a stale load-balanced HTTP view can no longer +// freeze sync into a false "synced". The advance must also stamp liveness and wake +// the sync loop. +func TestEthereumFeedHeaderAdvancesTipDespiteStaleHTTP(t *testing.T) { + b := &EthereumRPC{ + ChainConfig: &Configuration{AverageBlockTimeMs: 2000}, + Timeout: time.Second, + } + b.newBlockNotifyCh = make(chan struct{}, 1) + // HTTP call path is pinned to a stale, lagging height; it must not be consulted. + b.Client = &stubHeaderClient{height: 100} + + b.onFeedHeader(stubHeader{n: 200}) + + h, err := b.getBestHeader() + if err != nil { + t.Fatal(err) + } + if got := h.Number().Int64(); got != 200 { + t.Fatalf("tip = %d, want 200 (the feed header), not the stale HTTP height 100", got) + } + if b.lastSubNotifyNs.Load() == 0 { + t.Fatal("feed advance did not stamp subscription liveness") + } + select { + case <-b.newBlockNotifyCh: + default: + t.Fatal("feed advance did not wake the sync loop") + } +} + +// The cached tip must not regress to a lower height reported by a lagging +// load-balancer node (which would trip a spurious fork), but a same-height reorg +// must still be applied so resyncIndex can detect and handle it. +func TestEthereumSetBestHeaderMonotonic(t *testing.T) { + b := &EthereumRPC{Timeout: time.Second} + + if !b.setBestHeader(stubHeader{n: 200}, true) { + t.Fatal("first header should be accepted") + } + if b.setBestHeader(stubHeader{n: 150}, true) { + t.Fatal("a lower height must be rejected under a monotonic update") + } + if h, _ := b.getBestHeader(); h.Number().Int64() != 200 { + t.Fatalf("tip = %d, want 200 retained", h.Number().Int64()) + } + if !b.setBestHeader(stubHeader{n: 200, h: "reorg"}, true) { + t.Fatal("a same-height tip reorg must be applied") + } + // A non-monotonic update (the authoritative-feed/Tron path) may move down. + if !b.setBestHeader(stubHeader{n: 150}, false) { + t.Fatal("a non-monotonic update should accept a lower height") + } +} + +// A feed that re-delivers the same head (a stuck upstream) is not progress: +// liveness must not be refreshed and the sync loop must not be woken, so the +// watchdog can eventually treat the feed as stale. +func TestEthereumIdenticalFeedHeaderDoesNotRefreshLiveness(t *testing.T) { + b := &EthereumRPC{} + b.newBlockNotifyCh = make(chan struct{}, 1) + + b.onFeedHeader(stubHeader{n: 100}) + first := b.lastSubNotifyNs.Load() + if first == 0 { + t.Fatal("first feed header should stamp liveness") + } + select { // drain the wake-up from the first delivery + case <-b.newBlockNotifyCh: + default: + } + + b.onFeedHeader(stubHeader{n: 100}) // identical head: no progress + if b.lastSubNotifyNs.Load() != first { + t.Fatal("an identical feed header must not refresh liveness") + } + select { + case <-b.newBlockNotifyCh: + t.Fatal("an identical feed header must not wake the sync loop") + default: + } +} + +// A websocket backend behind a load balancer can accept the TCP socket but never +// complete the upgrade handshake — the silent stall tipWatchdog exists to heal. +// dialRPC must honor dialTimeout instead of blocking on context.Background() +// forever; otherwise reconnectRPC, and with it the lone tipWatchdog goroutine that +// is the sole feed-liveness healer, parks indefinitely, the cached tip stays frozen, +// and resyncIndex silently reports a false syncNotNeeded until a restart. +func TestDialRPCBoundsHungHandshake(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + // Accept the dial but swallow the upgrade request and never answer it, so the + // client is left waiting on the handshake response — the load-balancer blackhole. + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + io.Copy(io.Discard, c) + }() + + prev := dialTimeout + dialTimeout = 250 * time.Millisecond + defer func() { dialTimeout = prev }() + + done := make(chan error, 1) + start := time.Now() + go func() { + _, err := dialRPC("ws://" + ln.Addr().String()) + done <- err + }() + + select { + case err := <-done: + if err == nil { + t.Fatal("dialRPC returned a client though the backend never completed the WS handshake") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("dialRPC took %s; expected it bounded near dialTimeout=%s, not effectively unbounded", elapsed, dialTimeout) + } + case <-time.After(5 * time.Second): + t.Fatal("dialRPC did not return: a hung handshake parks reconnectRPC and the lone tipWatchdog goroutine forever") + } +} + +// fakeSilentSub models a newHeads subscription that is established successfully but +// never delivers a header and never errors — the silent stall behind a load +// balancer that drops the upstream without closing the socket. +type fakeSilentSub struct{ errCh chan error } + +func (s *fakeSilentSub) Err() <-chan error { return s.errCh } +func (s *fakeSilentSub) Unsubscribe() {} + +// fakeRPCClient hands out a fakeSilentSub for every EthSubscribe. +type fakeRPCClient struct{} + +func (c *fakeRPCClient) EthSubscribe(context.Context, interface{}, ...interface{}) (bchain.EVMClientSubscription, error) { + return &fakeSilentSub{errCh: make(chan error)}, nil +} +func (c *fakeRPCClient) CallContext(context.Context, interface{}, string, ...interface{}) error { + return nil +} +func (c *fakeRPCClient) Close() {} + +// A newHeads subscription that is established but never delivers a header (a feed +// born silent behind a load balancer) must still arm the watchdog's staleness +// clock. Liveness used to be stamped only on a tip advance, so such a feed left +// lastSubNotifyNs at 0 and the watchdog's `if lastNs == 0 { return }` gate disabled +// it forever: the cached tip froze and resyncIndex reported a silent false +// "synced" with no error or metric. subscribeEvents must seed liveness at +// subscribe time so the feed ages past the threshold and the watchdog can recover. +func TestSubscribeEventsArmsLivenessOnSilentFeed(t *testing.T) { + b := &EthereumRPC{ + // 12s average -> 5min threshold / 60s sample, so the watchdog goroutine + // started by subscribeEvents cannot fire (and reconnect) during the test. + ChainConfig: &Configuration{AverageBlockTimeMs: 12000, DisableMempoolSync: true}, + Timeout: time.Second, + } + b.NewBlock = NewEthereumNewBlock() + b.newBlockNotifyCh = make(chan struct{}, 1) + b.RPC = &fakeRPCClient{} + + if got := b.lastSubNotifyNs.Load(); got != 0 { + t.Fatalf("precondition: lastSubNotifyNs = %d, want 0 before subscribe", got) + } + if err := b.subscribeEvents(); err != nil { + t.Fatal(err) + } + if b.lastSubNotifyNs.Load() == 0 { + t.Fatal("subscribeEvents left liveness at 0 for a silent feed; tipWatchdog would stay disabled forever") + } +} 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 new file mode 100644 index 0000000000..3accfa8956 --- /dev/null +++ b/bchain/coins/eth/evm.go @@ -0,0 +1,175 @@ +package eth + +import ( + "context" + "math/big" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/trezor/blockbook/bchain" +) + +// EthereumClient wraps a client to implement the EVMClient interface +type EthereumClient struct { + *ethclient.Client +} + +// HeaderByNumber returns a block header that implements the EVMHeader interface +func (c *EthereumClient) HeaderByNumber(ctx context.Context, number *big.Int) (bchain.EVMHeader, error) { + h, err := c.Client.HeaderByNumber(ctx, number) + if err != nil { + return nil, err + } + + return &EthereumHeader{Header: h}, nil +} + +// EstimateGas returns the current estimated gas cost for executing a transaction +func (c *EthereumClient) 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 +func (c *EthereumClient) BalanceAt(ctx context.Context, addrDesc bchain.AddressDescriptor, blockNumber *big.Int) (*big.Int, error) { + return c.Client.BalanceAt(ctx, common.BytesToAddress(addrDesc), blockNumber) +} + +// NonceAt returns the nonce for the given account at a specific block, or latest known block if no block number is provided +func (c *EthereumClient) NonceAt(ctx context.Context, addrDesc bchain.AddressDescriptor, blockNumber *big.Int) (uint64, error) { + return c.Client.NonceAt(ctx, common.BytesToAddress(addrDesc), blockNumber) +} + +// EthereumRPCClient wraps an rpc client to implement the EVMRPCClient interface +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...) + if err != nil { + return nil, err + } + + return &EthereumClientSubscription{ClientSubscription: sub}, nil +} + +// EthereumHeader wraps a block header to implement the EVMHeader interface +type EthereumHeader struct { + *types.Header +} + +// Hash returns the block hash as a hex string +func (h *EthereumHeader) Hash() string { + return h.Header.Hash().Hex() +} + +// Number returns the block number +func (h *EthereumHeader) Number() *big.Int { + return h.Header.Number +} + +// Difficulty returns the block difficulty +func (h *EthereumHeader) Difficulty() *big.Int { + return h.Header.Difficulty +} + +// EthereumHash wraps a transaction hash to implement the EVMHash interface +type EthereumHash struct { + common.Hash +} + +// EthereumClientSubscription wraps a client subcription to implement the EVMClientSubscription interface +type EthereumClientSubscription struct { + *rpc.ClientSubscription +} + +// EthereumNewBlock wraps a block header channel to implement the EVMNewBlockSubscriber interface +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 +} + +// Read from the underlying channel and return a block header that implements the EVMHeader interface +func (s *EthereumNewBlock) Read() (bchain.EVMHeader, bool) { + h, ok := <-s.channel + return &EthereumHeader{Header: h}, ok +} + +// Close the underlying channel +func (s *EthereumNewBlock) Close() { + close(s.channel) +} + +// EthereumNewTx wraps a transaction hash channel to implement the EVMNewTxSubscriber interface +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 +} + +// Read from the underlying channel and return a transaction hash that implements the EVMHash interface +func (s *EthereumNewTx) Read() (bchain.EVMHash, bool) { + h, ok := <-s.channel + return &EthereumHash{Hash: h}, ok +} + +// Close the underlying channel +func (s *EthereumNewTx) Close() { + close(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/nonce_test.go b/bchain/coins/eth/nonce_test.go new file mode 100644 index 0000000000..33d5a5942a --- /dev/null +++ b/bchain/coins/eth/nonce_test.go @@ -0,0 +1,248 @@ +//go:build unittest + +package eth + +import ( + "context" + "errors" + "testing" + "time" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rpc" + "github.com/trezor/blockbook/bchain" +) + +// nonceTagFromArgs extracts the block tag ("pending"/"latest") from eth_getTransactionCount args. +func nonceTagFromArgs(args []interface{}) string { + if len(args) >= 2 { + if tag, ok := args[1].(string); ok { + return tag + } + } + return "" +} + +// nonceBatchStub implements bchain.EVMRPCClient AND BatchCallContext, serving +// eth_getTransactionCount per block tag. It records which tags were queried so tests can +// assert that the "latest" call is only made when the confirmed nonce is requested. +type nonceBatchStub struct { + results map[string]string // tag -> hex result + errs map[string]error // tag -> per-call error + batchErr error // transport-level error from BatchCallContext + queried []string +} + +func (s *nonceBatchStub) EthSubscribe(context.Context, interface{}, ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, errors.New("not implemented") +} + +func (s *nonceBatchStub) Close() {} + +func (s *nonceBatchStub) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + tag := nonceTagFromArgs(args) + s.queried = append(s.queried, tag) + if err := s.errs[tag]; err != nil { + return err + } + p, ok := result.(*string) + if !ok { + return errors.New("unexpected result type") + } + *p = s.results[tag] + return nil +} + +func (s *nonceBatchStub) BatchCallContext(ctx context.Context, batch []rpc.BatchElem) error { + if s.batchErr != nil { + return s.batchErr + } + for i := range batch { + tag := nonceTagFromArgs(batch[i].Args) + s.queried = append(s.queried, tag) + if err := s.errs[tag]; err != nil { + batch[i].Error = err + continue + } + if p, ok := batch[i].Result.(*string); ok { + *p = s.results[tag] + } + } + return nil +} + +// nonceSeqStub implements bchain.EVMRPCClient WITHOUT BatchCallContext, to exercise the +// sequential fallback path of getNoncesRPC. +type nonceSeqStub struct { + results map[string]string + errs map[string]error + queried []string +} + +func (s *nonceSeqStub) EthSubscribe(context.Context, interface{}, ...interface{}) (bchain.EVMClientSubscription, error) { + return nil, errors.New("not implemented") +} + +func (s *nonceSeqStub) Close() {} + +func (s *nonceSeqStub) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + tag := nonceTagFromArgs(args) + s.queried = append(s.queried, tag) + if err := s.errs[tag]; err != nil { + return err + } + p, ok := result.(*string) + if !ok { + return errors.New("unexpected result type") + } + *p = s.results[tag] + return nil +} + +var nonceTestAddr = bchain.AddressDescriptor(ethcommon.HexToAddress("0x4Bda106325C335dF99eab7fE363cAC8A0ba2a24D").Bytes()) + +func TestEthereumTypeGetNonces_GatedOff_FetchesPendingOnly(t *testing.T) { + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4", "latest": "0x2"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second} + + pending, confirmed, confirmedOK, err := b.EthereumTypeGetNonces(nonceTestAddr, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 4 { + t.Errorf("pending = %d, want 4", pending) + } + if confirmedOK { + t.Errorf("confirmedOK = true, want false when not requested") + } + if confirmed != 0 { + t.Errorf("confirmed = %d, want 0 when not requested", confirmed) + } + // the latest tag must not be queried when confirmed nonce is not requested + if len(stub.queried) != 1 || stub.queried[0] != "pending" { + t.Errorf("queried tags = %v, want exactly [pending]", stub.queried) + } +} + +func TestEthereumTypeGetNonces_GatedOn_Batched(t *testing.T) { + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4", "latest": "0x2"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second} + + pending, confirmed, confirmedOK, err := b.EthereumTypeGetNonces(nonceTestAddr, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 4 || confirmed != 2 || !confirmedOK { + t.Errorf("got (pending=%d confirmed=%d ok=%v), want (4 2 true)", pending, confirmed, confirmedOK) + } +} + +func TestEthereumTypeGetNonces_GatedOn_ConfirmedFailureIsBestEffort(t *testing.T) { + // the latest sub-call fails but pending succeeds: pending must still be returned, + // confirmed omitted, and NO error surfaced (so the whole address response survives) + stub := &nonceBatchStub{ + results: map[string]string{"pending": "0x4"}, + errs: map[string]error{"latest": errors.New("boom")}, + } + b := &EthereumRPC{RPC: stub, Timeout: time.Second} + + pending, confirmed, confirmedOK, err := b.EthereumTypeGetNonces(nonceTestAddr, true) + if err != nil { + t.Fatalf("confirmed-nonce failure must not be fatal, got error: %v", err) + } + if pending != 4 { + t.Errorf("pending = %d, want 4", pending) + } + if confirmedOK || confirmed != 0 { + t.Errorf("got (confirmed=%d ok=%v), want (0 false) on best-effort failure", confirmed, confirmedOK) + } +} + +func TestEthereumTypeGetNonces_GatedOn_ConfirmedDecodeFailureIsBestEffort(t *testing.T) { + // the latest sub-call SUCCEEDS but returns an unparsable hex value (a backend answering 200 + // with garbage). This is a distinct best-effort failure mode from a transport/lookup error and + // exercises the decode branch of decodeConfirmedNonce: pending must still be returned, confirmed + // omitted, and NO error surfaced. + stub := &nonceBatchStub{results: map[string]string{"pending": "0x4", "latest": "0xZZ"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second} + + pending, confirmed, confirmedOK, err := b.EthereumTypeGetNonces(nonceTestAddr, true) + if err != nil { + t.Fatalf("unparsable confirmed nonce must not be fatal, got error: %v", err) + } + if pending != 4 { + t.Errorf("pending = %d, want 4", pending) + } + if confirmedOK || confirmed != 0 { + t.Errorf("got (confirmed=%d ok=%v), want (0 false) on unparsable confirmed nonce", confirmed, confirmedOK) + } +} + +func TestEthereumTypeGetNonces_GatedOn_PendingFailureIsFatal(t *testing.T) { + stub := &nonceBatchStub{ + results: map[string]string{"latest": "0x2"}, + errs: map[string]error{"pending": errors.New("boom")}, + } + b := &EthereumRPC{RPC: stub, Timeout: time.Second} + + if _, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, true); err == nil { + t.Fatal("expected fatal error when the required pending nonce cannot be obtained") + } +} + +func TestEthereumTypeGetNonces_GatedOn_BatchTransportFailureIsFatal(t *testing.T) { + stub := &nonceBatchStub{batchErr: errors.New("transport down")} + b := &EthereumRPC{RPC: stub, Timeout: time.Second} + + if _, _, _, err := b.EthereumTypeGetNonces(nonceTestAddr, true); err == nil { + t.Fatal("expected fatal error on batch transport failure") + } +} + +func TestEthereumTypeGetNonces_SequentialFallback(t *testing.T) { + // a client without BatchCallContext must fall back to two sequential calls + stub := &nonceSeqStub{results: map[string]string{"pending": "0x4", "latest": "0x2"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second} + + pending, confirmed, confirmedOK, err := b.EthereumTypeGetNonces(nonceTestAddr, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pending != 4 || confirmed != 2 || !confirmedOK { + t.Errorf("got (pending=%d confirmed=%d ok=%v), want (4 2 true)", pending, confirmed, confirmedOK) + } + if len(stub.queried) != 2 { + t.Errorf("queried %d times, want 2 sequential calls (%v)", len(stub.queried), stub.queried) + } +} + +func TestEthereumTypeGetNonces_SequentialFallback_ConfirmedFailureIsBestEffort(t *testing.T) { + stub := &nonceSeqStub{ + results: map[string]string{"pending": "0x4"}, + errs: map[string]error{"latest": errors.New("boom")}, + } + b := &EthereumRPC{RPC: stub, Timeout: time.Second} + + pending, _, confirmedOK, err := b.EthereumTypeGetNonces(nonceTestAddr, true) + if err != nil { + t.Fatalf("confirmed-nonce failure must not be fatal, got error: %v", err) + } + if pending != 4 || confirmedOK { + t.Errorf("got (pending=%d ok=%v), want (4 false)", pending, confirmedOK) + } +} + +func TestEthereumTypeGetNonces_SequentialFallback_ConfirmedDecodeFailureIsBestEffort(t *testing.T) { + // sequential-fallback counterpart of the batched decode-failure case: an unparsable latest + // result must be best-effort (pending returned, confirmedOK=false, no error) + stub := &nonceSeqStub{results: map[string]string{"pending": "0x4", "latest": "0xZZ"}} + b := &EthereumRPC{RPC: stub, Timeout: time.Second} + + pending, _, confirmedOK, err := b.EthereumTypeGetNonces(nonceTestAddr, true) + if err != nil { + t.Fatalf("unparsable confirmed nonce must not be fatal, got error: %v", err) + } + if pending != 4 || confirmedOK { + t.Errorf("got (pending=%d ok=%v), want (4 false)", pending, confirmedOK) + } +} 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/firomsgtx.go b/bchain/coins/firo/firomsgtx.go new file mode 100644 index 0000000000..33a99eb7d0 --- /dev/null +++ b/bchain/coins/firo/firomsgtx.go @@ -0,0 +1,64 @@ +package firo + +import ( + "bytes" + "io" + + "github.com/martinboehm/btcd/chaincfg/chainhash" + "github.com/martinboehm/btcd/wire" +) + +// FiroMsgTx encapsulate firo tx and extra +type FiroMsgTx struct { + wire.MsgTx + Extra []byte +} + +// TxHash calculate hash of transaction +func (msg *FiroMsgTx) TxHash() chainhash.Hash { + extraSize := uint64(len(msg.Extra)) + sizeOfExtraSize := 0 + if extraSize != 0 { + sizeOfExtraSize = wire.VarIntSerializeSize(extraSize) + } + + // Original payload + buf := bytes.NewBuffer(make([]byte, 0, + msg.SerializeSizeStripped()+sizeOfExtraSize+len(msg.Extra))) + _ = msg.SerializeNoWitness(buf) + + // Extra payload + if extraSize != 0 { + wire.WriteVarInt(buf, 0, extraSize) + buf.Write(msg.Extra) + } + + return chainhash.DoubleHashH(buf.Bytes()) +} + +// FiroDecode to decode bitcoin tx and extra +func (msg *FiroMsgTx) FiroDecode(r io.Reader, pver uint32, enc wire.MessageEncoding) error { + if err := msg.MsgTx.BtcDecode(r, pver, enc); err != nil { + return err + } + + // extra + version := uint32(msg.Version) + txVersion := version & 0xffff + txType := (version >> 16) & 0xffff + if txVersion == 3 && txType != 0 { + + extraSize, err := wire.ReadVarInt(r, 0) + if err != nil { + return err + } + + msg.Extra = make([]byte, extraSize) + _, err = io.ReadFull(r, msg.Extra[:]) + if err != nil { + return err + } + } + + return nil +} diff --git a/bchain/coins/firo/firoparser.go b/bchain/coins/firo/firoparser.go new file mode 100644 index 0000000000..742cb467d8 --- /dev/null +++ b/bchain/coins/firo/firoparser.go @@ -0,0 +1,360 @@ +package firo + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "io" + + "github.com/martinboehm/btcd/chaincfg/chainhash" + "github.com/martinboehm/btcd/wire" + "github.com/martinboehm/btcutil/chaincfg" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" +) + +const ( + OpZeroCoinMint = 0xc1 + OpZeroCoinSpend = 0xc2 + OpSigmaMint = 0xc3 + OpSigmaSpend = 0xc4 + OpLelantusMint = 0xc5 + OpLelantusJMint = 0xc6 + OpLelantusJoinSplit = 0xc7 + OpLelantusJoinSplitPayload = 0xc9 + + MainnetMagic wire.BitcoinNet = 0xe3d9fef1 + TestnetMagic wire.BitcoinNet = 0xcffcbeea + RegtestMagic wire.BitcoinNet = 0xfabfb5da + + GenesisBlockTime = 1414776286 + SwitchToMTPBlockHeader = 1544443200 + SwitchToProgPowBlockHeaderTestnet = 1630069200 + SwitchToProgPowBlockHeaderMainnet = 1635228000 + MTPL = 64 + + SpendTxID = "0000000000000000000000000000000000000000000000000000000000000000" + + TransactionQuorumCommitmentType = 6 +) + +var ( + MainNetParams chaincfg.Params + TestNetParams chaincfg.Params + RegtestParams chaincfg.Params +) + +func init() { + // mainnet + MainNetParams = chaincfg.MainNetParams + MainNetParams.Net = MainnetMagic + + MainNetParams.AddressMagicLen = 1 + MainNetParams.PubKeyHashAddrID = []byte{0x52} + MainNetParams.ScriptHashAddrID = []byte{0x07} + + // testnet + TestNetParams = chaincfg.TestNet3Params + TestNetParams.Net = TestnetMagic + + TestNetParams.AddressMagicLen = 1 + TestNetParams.PubKeyHashAddrID = []byte{0x41} + TestNetParams.ScriptHashAddrID = []byte{0xb2} + + // regtest + RegtestParams = chaincfg.RegressionNetParams + RegtestParams.Net = RegtestMagic +} + +// FiroParser handle +type FiroParser struct { + *btc.BitcoinLikeParser +} + +// NewFiroParser returns new FiroParser instance +func NewFiroParser(params *chaincfg.Params, c *btc.Configuration) *FiroParser { + return &FiroParser{ + BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c), + } +} + +// GetChainParams contains network parameters for the main Firo network, +// the regression test Firo network, the test Firo network and +// the simulation test Firo network, in this order +func GetChainParams(chain string) *chaincfg.Params { + if !chaincfg.IsRegistered(&MainNetParams) { + err := chaincfg.Register(&MainNetParams) + if err == nil { + err = chaincfg.Register(&TestNetParams) + } + if err == nil { + err = chaincfg.Register(&RegtestParams) + } + if err != nil { + panic(err) + } + } + switch chain { + case "test": + return &TestNetParams + case "regtest": + return &RegtestParams + default: + return &MainNetParams + } +} + +// GetAddressesFromAddrDesc returns addresses for given address descriptor with flag if the addresses are searchable +func (p *FiroParser) GetAddressesFromAddrDesc(addrDesc bchain.AddressDescriptor) ([]string, bool, error) { + + if len(addrDesc) > 0 { + switch addrDesc[0] { + case OpZeroCoinMint: + return []string{"Zeromint"}, false, nil + case OpZeroCoinSpend: + return []string{"Zerospend"}, false, nil + case OpSigmaMint: + return []string{"Sigmamint"}, false, nil + case OpSigmaSpend: + return []string{"Sigmaspend"}, false, nil + case OpLelantusMint: + return []string{"LelantusMint"}, false, nil + case OpLelantusJMint: + return []string{"LelantusJMint"}, false, nil + case OpLelantusJoinSplit: + return []string{"LelantusJoinSplit"}, false, nil + case OpLelantusJoinSplitPayload: + return []string{"LelantusJoinSplit"}, false, nil + } + } + + return p.OutputScriptToAddressesFunc(addrDesc) +} + +// PackTx packs transaction to byte array using protobuf +func (p *FiroParser) PackTx(tx *bchain.Tx, height uint32, blockTime int64) ([]byte, error) { + return p.BaseParser.PackTx(tx, height, blockTime) +} + +// UnpackTx unpacks transaction from protobuf byte array +func (p *FiroParser) UnpackTx(buf []byte) (*bchain.Tx, uint32, error) { + return p.BaseParser.UnpackTx(buf) +} + +// TxFromFiroMsgTx converts bitcoin wire Tx to bchain.Tx +func (p *FiroParser) TxFromFiroMsgTx(t *FiroMsgTx, parseAddresses bool) bchain.Tx { + btx := p.TxFromMsgTx(&t.MsgTx, parseAddresses) + + // NOTE: wire.MsgTx.TxHash() doesn't include extra + btx.Txid = t.TxHash().String() + + return btx +} + +// ParseBlock parses raw block to our Block struct +func (p *FiroParser) ParseBlock(b []byte) (*bchain.Block, error) { + reader := bytes.NewReader(b) + + // parse standard block header first + header, err := parseBlockHeader(reader) + if err != nil { + return nil, err + } + + // then ProgPow or MTP header + if isProgPow(header, p.Params.Net == TestnetMagic) { + progPowHeader := ProgPowBlockHeader{} + + // header + err = binary.Read(reader, binary.LittleEndian, &progPowHeader) + if err != nil { + return nil, err + } + } else { + if isMTP(header) { + mtpHeader := MTPBlockHeader{} + mtpHashDataRoot := MTPHashDataRoot{} + + // header + err = binary.Read(reader, binary.LittleEndian, &mtpHeader) + if err != nil { + return nil, err + } + + // hash data root + err = binary.Read(reader, binary.LittleEndian, &mtpHashDataRoot) + if err != nil { + return nil, err + } + + isAllZero := true + for i := 0; i < 16; i++ { + if mtpHashDataRoot.HashRootMTP[i] != 0 { + isAllZero = false + break + } + } + + if !isAllZero { + // hash data + mtpHashData := MTPHashData{} + err = binary.Read(reader, binary.LittleEndian, &mtpHashData) + if err != nil { + return nil, err + } + + // proof + for i := 0; i < MTPL*3; i++ { + var numberProofBlocks uint8 + + 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 + } + } + } + } + } + } + + // parse txs + ntx, err := wire.ReadVarInt(reader, 0) + if err != nil { + return nil, err + } + + txs := make([]bchain.Tx, ntx) + + for i := uint64(0); i < ntx; i++ { + tx := FiroMsgTx{} + + // read version and seek back + var version uint32 = 0 + if err = binary.Read(reader, binary.LittleEndian, &version); err != nil { + return nil, err + } + + if _, err = reader.Seek(-4, io.SeekCurrent); err != nil { + return nil, err + } + + txVersion := version & 0xffff + txType := (version >> 16) & 0xffff + + enc := wire.WitnessEncoding + + // transaction quorum commitment could not be parsed with witness flag + if txVersion == 3 && txType == TransactionQuorumCommitmentType { + enc = wire.BaseEncoding + } + + if err = tx.FiroDecode(reader, 0, enc); err != nil { + return nil, err + } + + btx := p.TxFromFiroMsgTx(&tx, false) + + if err = p.parseFiroTx(&btx); err != nil { + return nil, err + } + + txs[i] = btx + } + + 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(), + }, + Txs: txs, + }, nil +} + +// ParseTxFromJson parses JSON message containing transaction and returns Tx struct +func (p *FiroParser) ParseTxFromJson(msg json.RawMessage) (*bchain.Tx, error) { + var tx bchain.Tx + err := json.Unmarshal(msg, &tx) + if err != nil { + return nil, err + } + + for i := range tx.Vout { + vout := &tx.Vout[i] + // convert vout.JsonValue to big.Int and clear it, it is only temporary value used for unmarshal + vout.ValueSat, err = p.AmountToBigInt(vout.JsonValue) + if err != nil { + return nil, err + } + vout.JsonValue = "" + } + + p.parseFiroTx(&tx) + + return &tx, nil +} + +func (p *FiroParser) parseFiroTx(tx *bchain.Tx) error { + for i := range tx.Vin { + vin := &tx.Vin[i] + + // FIXME: right now we treat zerocoin spend vin as coinbase + // change this after blockbook support special type of vin + if vin.Txid == SpendTxID { + vin.Coinbase = vin.Txid + vin.Txid = "" + vin.Sequence = 0 + vin.Vout = 0 + } + } + + return nil +} + +func parseBlockHeader(r io.Reader) (*wire.BlockHeader, error) { + h := &wire.BlockHeader{} + err := h.Deserialize(r) + return h, err +} + +func isMTP(h *wire.BlockHeader) bool { + epoch := h.Timestamp.Unix() + + // the genesis block never be MTP block + return epoch > GenesisBlockTime && epoch >= SwitchToMTPBlockHeader +} + +func isProgPow(h *wire.BlockHeader, isTestNet bool) bool { + epoch := h.Timestamp.Unix() + + // the genesis block never be MTP block + return isTestNet && epoch >= SwitchToProgPowBlockHeaderTestnet || !isTestNet && epoch >= SwitchToProgPowBlockHeaderMainnet +} + +type MTPHashDataRoot struct { + HashRootMTP [16]uint8 +} + +type MTPHashData struct { + BlockMTP [128][128]uint64 +} + +type MTPBlockHeader struct { + VersionMTP int32 + MTPHashValue chainhash.Hash + Reserved1 chainhash.Hash + Reserved2 chainhash.Hash +} + +type ProgPowBlockHeader struct { + Nonce64 int64 + MixHash chainhash.Hash +} diff --git a/bchain/coins/firo/firoparser_test.go b/bchain/coins/firo/firoparser_test.go new file mode 100644 index 0000000000..253323df25 --- /dev/null +++ b/bchain/coins/firo/firoparser_test.go @@ -0,0 +1,984 @@ +//go:build unittest + +package firo + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "io/ioutil" + "math/big" + "os" + "reflect" + "strings" + "testing" + + "github.com/martinboehm/btcd/wire" + "github.com/martinboehm/btcutil/chaincfg" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" +) + +var ( + testTx1, testTx2, testTx3, testTx4, testTx5, testTx6 bchain.Tx + rawTestTx1, rawTestTx2, rawTestTx3, rawTestTx4, rawTestTx5, rawTestTx6 string + testTxPacked1, testTxPacked2, testTxPacked3, testTxPacked4, testTxPacked5, testTxPacked6 string + rawBlock1, rawBlock2, rawBlock3 string + jsonTx json.RawMessage +) + +func readHexs(path string) []string { + raw, err := ioutil.ReadFile(path) + if err != nil { + panic(err) + } + rawStr := string(raw) + raws := strings.Split(rawStr, "\n") + return raws +} + +func init() { + rawBlocks := readHexs("./testdata/rawblock.hex") + rawBlock1 = rawBlocks[0] + rawBlock2 = rawBlocks[1] + rawBlock3 = rawBlocks[2] + + hextxs := readHexs("./testdata/txs.hex") + rawTestTx1 = hextxs[0] + rawTestTx2 = hextxs[1] + rawTestTx3 = hextxs[2] + rawTestTx4 = hextxs[3] + rawTestTx5 = hextxs[4] + rawTestTx6 = hextxs[5] + + rawSpendHex := readHexs("./testdata/rawspend.hex")[0] + + rawSpendTx, err := ioutil.ReadFile("./testdata/spendtx.json") + if err != nil { + panic(err) + } + jsonTx = json.RawMessage(rawSpendTx) + + testTxPackeds := readHexs("./testdata/packedtxs.hex") + testTxPacked1 = testTxPackeds[0] + testTxPacked2 = testTxPackeds[1] + testTxPacked3 = testTxPackeds[2] + testTxPacked4 = testTxPackeds[3] + testTxPacked5 = testTxPackeds[4] + testTxPacked6 = testTxPackeds[5] + + testTx1 = bchain.Tx{ + Hex: rawTestTx1, + Blocktime: 1533980594, + Time: 1533980594, + Txid: "9d9e759dd970d86df9e105a7d4f671543bc16a03b6c5d2b48895f2a00aa7dd23", + LockTime: 99688, + Vin: []bchain.Vin{ + { + ScriptSig: bchain.ScriptSig{ + Hex: "47304402205b7d9c9aae790b69017651e10134735928df3b4a4a2feacc9568eb4fa133ed5902203f21a399385ce29dd79831ea34aa535612aa4314c5bd0b002bbbc9bcd2de1436012102b8d462740c99032a00083ac7028879acec244849e54ad0a04ea87f632f54b1d2", + }, + Txid: "463a2d66b04636a014da35724909425f3403d9d786dd4f79780de50d47b18716", + Vout: 1, + Sequence: 4294967294, + }, + }, + Vout: []bchain.Vout{ + { + ValueSat: *big.NewInt(100000000), + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "c10280004c80f767f3ee79953c67a7ed386dcccf1243619eb4bbbe414a3982dd94a83c1b69ac52d6ab3b653a3e05c4e4516c8dfe1e58ada40461bc5835a4a0d0387a51c29ac11b72ae25bbcdef745f50ad08f08b3e9bc2c31a35444398a490e65ac090e9f341f1abdebe47e57e8237ac25d098e951b4164a35caea29f30acb50b12e4425df28", + }, + }, + { + ValueSat: *big.NewInt(871824000), + N: 1, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a914c963f917c7f23cb4243e079db33107571b87690588ac", + Addresses: []string{ + "aK5KKi8qqDbspcXFfDjx8UBGMouhYbYZVp", + }, + }, + }, + }, + } + + testTx2 = bchain.Tx{ + Hex: rawTestTx2, + Blocktime: 1481277009, + Time: 1481277009, + Txid: "3d721fdce2855e2b4a54b74a26edd58a7262e1f195b5acaaae7832be6e0b3d32", + LockTime: 0, + Vin: []bchain.Vin{ + { + Coinbase: rawSpendHex, + Txid: "0000000000000000000000000000000000000000000000000000000000000000", + Vout: 4294967295, + Sequence: 2, + }, + }, + Vout: []bchain.Vout{ + { + ValueSat: *big.NewInt(5000000000), + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a914b9e262e30df03e88ccea312652bc83ca7290c8fc88ac", + Addresses: []string{ + "aHfKwzFZMiSxDuNL4jts819nh57t2yJG1h", + }, + }, + }, + }, + } + + testTx3 = bchain.Tx{ + Hex: rawTestTx3, + Blocktime: 1547091829, + Time: 1547091829, + Txid: "96ae951083651f141d1fb2719c76d47e5a3ad421b81905f679c0edb60f2de0ff", + LockTime: 126200, + Vin: []bchain.Vin{ + { + ScriptSig: bchain.ScriptSig{ + Hex: "483045022100bdc6b51c114617e29e28390dc9b3ad95b833ca3d1f0429ba667c58a667f9124702204ca2ed362dd9ef723ddbdcf4185b47c28b127a36f46bc4717662be863309b3e601210387e7ff08b953e3736955408fc6ebcd8aa84a04cc4b45758ea29cc2cfe1820535", + }, + Txid: "448ccfd9c3f375be8701b86aff355a230dbe240334233f2ed476fcae6abd295d", + Vout: 1, + Sequence: 4294967294, + }, + }, + Vout: []bchain.Vout{ + { + ValueSat: *big.NewInt(42000000000), + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a91429bef7962c5c65a2f0f4f7d9ec791866c54f851688ac", + Addresses: []string{ + "a4XCDQ7AnRH9opZ4h6LcG3g7ocSV2SbBmS", + }, + }, + }, + { + ValueSat: *big.NewInt(107300000000), + N: 1, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a914e2cee7b71c3a4637dbdfe613f19f4b4f2d070d7f88ac", + Addresses: []string{ + "aMPiKHB3E1AGPi8kKLknx6j1L4JnKCGkLw", + }, + }, + }, + }, + } + + testTx4 = bchain.Tx{ + Hex: rawTestTx4, + Blocktime: 1533977563, + Time: 1533977563, + Txid: "914ccbdb72f593e5def15978cf5891e1384a1b85e89374fc1c440c074c6dd286", + LockTime: 0, + Vin: []bchain.Vin{ + { + Coinbase: "03a1860104dba36e5b082a00077c00000000052f6d70682f", + Sequence: 0, + }, + }, + Vout: []bchain.Vout{ + { + ValueSat: *big.NewInt(2800200000), + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a91436e086acf6561a68ba64196e7b92b606d0b8516688ac", + Addresses: []string{ + "a5idCcHN8WYxvFCeBXSXvMPrZHuBkZmqEJ", + }, + }, + }, + { + ValueSat: *big.NewInt(1500000000), + N: 1, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a914381a5dd1a279e8e63e67cde39ecfa61a99dd2ba288ac", + Addresses: []string{ + "a5q7Ad4okSFFVh5adyqx5DT21RTxJykpUM", + }, + }, + }, + { + ValueSat: *big.NewInt(100000000), + N: 2, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a9147d9ed014fc4e603fca7c2e3f9097fb7d0fb487fc88ac", + Addresses: []string{ + "aCAgTPgtYcA4EysU4UKC86EQd5cTtHtCcr", + }, + }, + }, + { + ValueSat: *big.NewInt(100000000), + N: 3, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a914bc7e5a5234db3ab82d74c396ad2b2af419b7517488ac", + Addresses: []string{ + "aHu897ivzmeFuLNB6956X6gyGeVNHUBRgD", + }, + }, + }, + { + ValueSat: *big.NewInt(100000000), + N: 4, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a914ff71b0c9c2a90c6164a50a2fb523eb54a8a6b55088ac", + Addresses: []string{ + "aQ18FBVFtnueucZKeVg4srhmzbpAeb1KoN", + }, + }, + }, + { + ValueSat: *big.NewInt(300000000), + N: 5, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a9140654dd9b856f2ece1d56cb4ee5043cd9398d962c88ac", + Addresses: []string{ + "a1HwTdCmQV3NspP2QqCGpehoFpi8NY4Zg3", + }, + }, + }, + { + ValueSat: *big.NewInt(100000000), + N: 6, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a9140b4bfb256ef4bfa360e3b9e66e53a0bd84d196bc88ac", + Addresses: []string{ + "a1kCCGddf5pMXSipLVD9hBG2MGGVNaJ15U", + }, + }, + }, + // TODO: test segwit + }, + } + + testTx5 = bchain.Tx{ + Hex: rawTestTx5, + Blocktime: 1591752749, + Time: 1591752749, + Txid: "8d1f32f35c32d2c127a7400dc1ec52049fbf0b8bcdf284cfaa3da59b6169a22d", + LockTime: 0, + Vin: []bchain.Vin{}, + Vout: []bchain.Vout{}, + } + + testTx6 = bchain.Tx{ + Hex: rawTestTx6, + Blocktime: 1591762049, + Time: 1591762049, + Txid: "e5767d3606230a65f150837a6f28b4f0e4c2702a683045df3883d57702739c61", + LockTime: 0, + Vin: []bchain.Vin{ + { + Coinbase: "02b4140101", + Sequence: 4294967295, + }, + }, + Vout: []bchain.Vout{ + { + ValueSat: *big.NewInt(1400000000), + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "2103fb09a216761d5e7f248294970c2370f7f84ce1ad564b8e7096b1e19116af1d52ac", + Addresses: []string{ + "TAn9Ghkp31myXRgejCj11wWVHT14Lsj349", + }, + }, + }, + { + ValueSat: *big.NewInt(50000000), + N: 1, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a914296134d2415bf1f2b518b3f673816d7e603b160088ac", + Addresses: []string{ + "TDk19wPKYq91i18qmY6U9FeTdTxwPeSveo", + }, + }, + }, + { + ValueSat: *big.NewInt(50000000), + N: 2, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a914e1e1dc06a889c1b6d3eb00eef7a96f6a7cfb884888ac", + Addresses: []string{ + "TWZZcDGkNixTAMtRBqzZkkMHbq1G6vUTk5", + }, + }, + }, + { + ValueSat: *big.NewInt(50000000), + N: 3, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a914ab03ecfddee6330497be894d16c29ae341c123aa88ac", + Addresses: []string{ + "TRZTFdNCKCKbLMQV8cZDkQN9Vwuuq4gDzT", + }, + }, + }, + { + ValueSat: *big.NewInt(150000000), + N: 4, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a9144281a58a1d5b2d3285e00cb45a8492debbdad4c588ac", + Addresses: []string{ + "TG2ruj59E5b1u9G3F7HQVs6pCcVDBxrQve", + }, + }, + }, + { + ValueSat: *big.NewInt(50000000), + N: 5, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a9141fd264c0bb53bd9fef18e2248ddf1383d6e811ae88ac", + Addresses: []string{ + "TCsTzQZKVn4fao8jDmB9zQBk9YQNEZ3XfS", + }, + }, + }, + { + ValueSat: *big.NewInt(750000000), + N: 6, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a91471a3892d164ffa3829078bf9ad5f114a3908ce5588ac", + Addresses: []string{ + "TLL5GQULX4uBfz7yXL6VcZyvzdKVv1RGxm", + }, + }, + }, + }, + } +} + +func TestMain(m *testing.M) { + c := m.Run() + chaincfg.ResetParams() + os.Exit(c) +} + +func TestGetAddrDesc(t *testing.T) { + type args struct { + tx bchain.Tx + parser *FiroParser + } + tests := []struct { + name string + args args + }{ + { + name: "firo-1", + args: args{ + tx: testTx1, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + }, + // FIXME: work around handle zerocoin spend as coinbase + // { + // name: "firo-2", + // args: args{ + // tx: testTx2, + // parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + // }, + // }, + { + name: "firo-3", + args: args{ + tx: testTx3, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for n, vout := range tt.args.tx.Vout { + got1, err := tt.args.parser.GetAddrDescFromVout(&vout) + if err != nil { + t.Errorf("getAddrDescFromVout() error = %v, vout = %d", err, n) + return + } + + // normal vout + if len(vout.ScriptPubKey.Addresses) >= 1 { + got2, err := tt.args.parser.GetAddrDescFromAddress(vout.ScriptPubKey.Addresses[0]) + if err != nil { + t.Errorf("getAddrDescFromAddress() error = %v, vout = %d", err, n) + return + } + if !bytes.Equal(got1, got2) { + t.Errorf("Address descriptors mismatch: got1 = %v, got2 = %v", got1, got2) + } + } + } + }) + } +} + +func TestGetAddrDescFromVoutForMint(t *testing.T) { + type args struct { + vout bchain.Vout + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "OP_RETURN", + args: args{vout: bchain.Vout{ScriptPubKey: bchain.ScriptPubKey{Hex: "6a072020f1686f6a20"}}}, + want: "6a072020f1686f6a20", + wantErr: false, + }, + { + name: "OP_ZEROCOINMINT", + args: args{vout: bchain.Vout{ScriptPubKey: bchain.ScriptPubKey{Hex: "c10280004c80f767f3ee79953c67a7ed386dcccf1243619eb4bbbe414a3982dd94a83c1b69ac52d6ab3b653a3e05c4e4516c8dfe1e58ada40461bc5835a4a0d0387a51c29ac11b72ae25bbcdef745f50ad08f08b3e9bc2c31a35444398a490e65ac090e9f341f1abdebe47e57e8237ac25d098e951b4164a35caea29f30acb50b12e4425df28"}}}, + want: "c10280004c80f767f3ee79953c67a7ed386dcccf1243619eb4bbbe414a3982dd94a83c1b69ac52d6ab3b653a3e05c4e4516c8dfe1e58ada40461bc5835a4a0d0387a51c29ac11b72ae25bbcdef745f50ad08f08b3e9bc2c31a35444398a490e65ac090e9f341f1abdebe47e57e8237ac25d098e951b4164a35caea29f30acb50b12e4425df28", + wantErr: false, + }, + { + name: "OP_SIGMAMINT", + args: args{vout: bchain.Vout{ScriptPubKey: bchain.ScriptPubKey{Hex: "c317dcee5b8b2c5b79728abc3a39abc54682b31a4e18f5abb6f34dc8089544763b0000"}}}, + want: "c317dcee5b8b2c5b79728abc3a39abc54682b31a4e18f5abb6f34dc8089544763b0000", + wantErr: false, + }, + } + parser := NewFiroParser(GetChainParams("main"), &btc.Configuration{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parser.GetAddrDescFromVout(&tt.args.vout) + if (err != nil) != tt.wantErr { + t.Errorf("GetAddrDescFromVout() error = %v, wantErr %v", err, tt.wantErr) + return + } + h := hex.EncodeToString(got) + if !reflect.DeepEqual(h, tt.want) { + t.Errorf("GetAddrDescFromVout() = %v, want %v", h, tt.want) + } + }) + } +} + +func TestGetAddressesFromAddrDescForMint(t *testing.T) { + type args struct { + script string + } + tests := []struct { + name string + args args + want []string + want2 bool + wantErr bool + }{ + { + name: "OP_RETURN hex", + args: args{script: "6a072020f1686f6a20"}, + want: []string{"OP_RETURN 2020f1686f6a20"}, + want2: false, + wantErr: false, + }, + { + name: "OP_ZEROCOINMINT size hex", + args: args{script: "c10280004c80f767f3ee79953c67a7ed386dcccf1243619eb4bbbe414a3982dd94a83c1b69ac52d6ab3b653a3e05c4e4516c8dfe1e58ada40461bc5835a4a0d0387a51c29ac11b72ae25bbcdef745f50ad08f08b3e9bc2c31a35444398a490e65ac090e9f341f1abdebe47e57e8237ac25d098e951b4164a35caea29f30acb50b12e4425df28"}, + want: []string{"Zeromint"}, + want2: false, + wantErr: false, + }, + { + name: "OP_SIGMAMINT size hex", + args: args{script: "c317dcee5b8b2c5b79728abc3a39abc54682b31a4e18f5abb6f34dc8089544763b0000"}, + want: []string{"Sigmamint"}, + want2: false, + wantErr: false, + }, + } + parser := NewFiroParser(GetChainParams("main"), &btc.Configuration{}) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, _ := hex.DecodeString(tt.args.script) + got, got2, 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) + } + if !reflect.DeepEqual(got2, tt.want2) { + t.Errorf("GetAddressesFromAddrDesc() = %v, want %v", got2, tt.want2) + } + }) + } +} + +func TestPackTx(t *testing.T) { + type args struct { + tx bchain.Tx + height uint32 + blockTime int64 + parser *FiroParser + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "firo-1", + args: args{ + tx: testTx1, + height: 100002, + blockTime: 1533980594, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: testTxPacked1, + wantErr: false, + }, + // FIXME: work around handle zerocoin spend as coinbase + // { + // name: "firo-2", + // args: args{ + // tx: testTx2, + // height: 11002, + // blockTime: 1481277009, + // parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + // }, + // want: testTxPacked2, + // wantErr: true, + // }, + { + name: "firo-3", + args: args{ + tx: testTx3, + height: 126202, + blockTime: 1547091829, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: testTxPacked3, + wantErr: false, + }, + { + name: "firo-coinbase", + args: args{ + tx: testTx4, + height: 100001, + blockTime: 1533977563, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: testTxPacked4, + wantErr: false, + }, + { + name: "firo-quorum-commitment-tx", + args: args{ + tx: testTx5, + height: 5268, + blockTime: 1591752749, + parser: NewFiroParser(GetChainParams("test"), &btc.Configuration{}), + }, + want: testTxPacked5, + wantErr: false, + }, + { + name: "firo-special-coinbase-tx", + args: args{ + tx: testTx6, + height: 5300, + blockTime: 1591762049, + parser: NewFiroParser(GetChainParams("test"), &btc.Configuration{}), + }, + want: testTxPacked6, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.args.parser.PackTx(&tt.args.tx, tt.args.height, tt.args.blockTime) + if (err != nil) != tt.wantErr { + t.Errorf("packTx() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + h := hex.EncodeToString(got) + if !reflect.DeepEqual(h, tt.want) { + t.Errorf("packTx() = %v, want %v", h, tt.want) + } + } + }) + } +} + +func TestUnpackTx(t *testing.T) { + type args struct { + packedTx string + parser *FiroParser + } + tests := []struct { + name string + args args + want *bchain.Tx + want1 uint32 + wantErr bool + }{ + { + name: "firo-1", + args: args{ + packedTx: testTxPacked1, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: &testTx1, + want1: 100002, + wantErr: false, + }, + // FIXME: work around handle zerocoin spend as coinbase + // { + // name: "firo-2", + // args: args{ + // packedTx: testTxPacked2, + // parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + // }, + // want: &testTx2, + // want1: 11002, + // wantErr: true, + // }, + { + name: "firo-3", + args: args{ + packedTx: testTxPacked3, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: &testTx3, + want1: 126202, + wantErr: false, + }, + { + name: "firo-coinbase", + args: args{ + packedTx: testTxPacked4, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: &testTx4, + want1: 100001, + wantErr: false, + }, + { + name: "firo-special-tx", + args: args{ + packedTx: testTxPacked5, + parser: NewFiroParser(GetChainParams("test"), &btc.Configuration{}), + }, + want: &testTx5, + want1: 5268, + wantErr: false, + }, + { + name: "firo-special-coinbase-tx", + args: args{ + packedTx: testTxPacked6, + parser: NewFiroParser(GetChainParams("test"), &btc.Configuration{}), + }, + want: &testTx6, + want1: 5300, + wantErr: false, + }, + } + for _, tt := range tests { + 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 + } + + if !tt.wantErr { + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("unpackTx() got = %v, want %v", got, tt.want) + } + if got1 != tt.want1 { + t.Errorf("unpackTx() got1 = %v, want %v", got1, tt.want1) + } + } + }) + } +} + +func TestParseBlock(t *testing.T) { + type args struct { + rawBlock string + parser *FiroParser + } + tests := []struct { + name string + args args + want *bchain.Block + wantTxs int + wantErr bool + }{ + { + name: "normal-block", + args: args{ + rawBlock: rawBlock1, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: &bchain.Block{ + BlockHeader: bchain.BlockHeader{ + Prev: "a3b419a943bdc31aba65d40fc71f12ceb4ef2edcf1c8bd6d83b839261387e0d9", + Size: 200286, + Time: 1547120622, + }, + }, + wantTxs: 3, + wantErr: false, + }, + { + name: "spend-block", + args: args{ + rawBlock: rawBlock2, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: &bchain.Block{ + BlockHeader: bchain.BlockHeader{ + Prev: "0fb6e382a25a9e298a533237f359cb6cd86a99afb8d98e3d981e650fd5012c00", + Size: 25298, + Time: 1482107572, + }, + }, + wantTxs: 4, + wantErr: false, + }, + { + name: "special-tx-block", + args: args{ + rawBlock: rawBlock3, + parser: NewFiroParser(GetChainParams("test"), &btc.Configuration{}), + }, + want: &bchain.Block{ + BlockHeader: bchain.BlockHeader{ + Prev: "12c117c25e52f71e8863eadd0ccc7cd7d45e7ef907cfadf99ca4b4d390cb1a0a", + Size: 200062, + Time: 1591752749, + }, + }, + wantTxs: 3, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, _ := hex.DecodeString(tt.args.rawBlock) + got, err := tt.args.parser.ParseBlock(b) + if (err != nil) != tt.wantErr { + t.Errorf("parseBlock() error = %+v", err) + } + + if got != nil { + + if !reflect.DeepEqual(got.BlockHeader, tt.want.BlockHeader) { + t.Errorf("parseBlock() got = %v, want %v", got.BlockHeader, tt.want.BlockHeader) + } + + if len(got.Txs) != tt.wantTxs { + t.Errorf("parseBlock() txs length got = %d, want %d", len(got.Txs), tt.wantTxs) + } + } + }) + } +} + +func TestDecodeTransaction(t *testing.T) { + type args struct { + enc wire.MessageEncoding + rawTransaction string + parser *FiroParser + privacyType byte // 0 as non privacy + } + tests := []struct { + name string + args args + want bchain.Tx + wantErr bool + }{ + { + name: "normal-transaction", + args: args{ + enc: wire.WitnessEncoding, + rawTransaction: rawTestTx1, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: testTx1, + }, + { + name: "coinbase-firospend", + args: args{ + enc: wire.WitnessEncoding, + rawTransaction: rawTestTx2, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + privacyType: OpSigmaSpend, + }, + want: testTx2, + }, + { + name: "normal-transaction-2", + args: args{ + enc: wire.WitnessEncoding, + rawTransaction: rawTestTx3, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: testTx3, + }, + { + name: "coinbase-transaction", + args: args{ + enc: wire.WitnessEncoding, + rawTransaction: rawTestTx4, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: testTx4, + }, + { + name: "quorum-commitment-transaction", + args: args{ + enc: wire.BaseEncoding, + rawTransaction: rawTestTx5, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: testTx5, + }, + { + name: "quorum-commitment-transaction-witness", + args: args{ + enc: wire.WitnessEncoding, + rawTransaction: rawTestTx5, + parser: NewFiroParser(GetChainParams("main"), &btc.Configuration{}), + }, + wantErr: true, + }, + { + name: "special-coinbase", + args: args{ + enc: wire.WitnessEncoding, + rawTransaction: rawTestTx6, + parser: NewFiroParser(GetChainParams("test"), &btc.Configuration{}), + }, + want: testTx6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, _ := hex.DecodeString(tt.args.rawTransaction) + r := bytes.NewReader(b) + + msg := FiroMsgTx{} + err := msg.FiroDecode(r, 0, tt.args.enc) + + if tt.wantErr { + if err == nil { + t.Errorf("Want error") + } + + return + } + + if err != nil { + t.Fatal(err) + } + + got := tt.args.parser.TxFromFiroMsgTx(&msg, true) + if pErr := tt.args.parser.parseFiroTx(&got); pErr != nil { + t.Fatal(pErr) + } + + if r.Len() != 0 { + t.Errorf("Expected EOF but there are remaining %d bytes to read", r.Len()) + } + + if len(got.Vin) != len(tt.want.Vin) { + t.Errorf("Check vin size, got %v, want %v", len(got.Vin), len(tt.want.Vin)) + } + + for i := 0; i != len(got.Vin); i++ { + if !reflect.DeepEqual(got.Vin[i].Addresses, tt.want.Vin[i].Addresses) { + t.Errorf("Check Addresses at input %d, got %v, want %v", + i, got.Vin[i].Addresses, tt.want.Vin[i].Addresses) + } + + if !reflect.DeepEqual(got.Vin[i].Coinbase, tt.want.Vin[i].Coinbase) { + t.Errorf("Check Coinbase at input %d, got %v, want %v", + i, got.Vin[i].Coinbase, tt.want.Vin[i].Coinbase) + } + + if !reflect.DeepEqual(got.Vin[i].ScriptSig, tt.want.Vin[i].ScriptSig) { + t.Errorf("Check ScriptSig at input %d, got %v, want %v", + i, got.Vin[i].ScriptSig, tt.want.Vin[i].ScriptSig) + } + + if !reflect.DeepEqual(got.Vin[i].Sequence, tt.want.Vin[i].Sequence) { + t.Errorf("Check Sequence at input %d, got %v, want %v", + i, got.Vin[i].Sequence, tt.want.Vin[i].Sequence) + } + + if tt.args.privacyType == 0 && !reflect.DeepEqual(got.Vin[i].Txid, tt.want.Vin[i].Txid) { + t.Errorf("Check Txid at input %d, got %v, want %v", + i, got.Vin[i].Txid, tt.want.Vin[i].Txid) + } + + if tt.args.privacyType == 0 && !reflect.DeepEqual(got.Vin[i].Vout, tt.want.Vin[i].Vout) { + t.Errorf("Check Vout at input %d, got %v, want %v", + i, got.Vin[i].Vout, tt.want.Vin[i].Vout) + } + } + + if len(got.Vout) != len(tt.want.Vout) { + t.Errorf("Check vout size, got %v, want %v", len(got.Vout), len(tt.want.Vout)) + } + + for i := 0; i != len(got.Vout); i++ { + if !reflect.DeepEqual(got.Vout[i].JsonValue, tt.want.Vout[i].JsonValue) { + t.Errorf("Check JsonValue at output %d, got %v, want %v", + i, got.Vout[i].JsonValue, tt.want.Vout[i].JsonValue) + } + + if !reflect.DeepEqual(got.Vout[i].N, tt.want.Vout[i].N) { + t.Errorf("Check N at output %d, got %v, want %v", + i, got.Vout[i].N, tt.want.Vout[i].N) + } + + // empty addresses and null should be the same + if !((len(got.Vout[i].ScriptPubKey.Addresses) == 0 && len(got.Vout[i].ScriptPubKey.Addresses) == len(tt.want.Vout[i].ScriptPubKey.Addresses)) || + reflect.DeepEqual(got.Vout[i].ScriptPubKey.Addresses, tt.want.Vout[i].ScriptPubKey.Addresses)) { + t.Errorf("Check ScriptPubKey.Addresses at output %d, got %v, want %v", + i, got.Vout[i].ScriptPubKey.Addresses, tt.want.Vout[i].ScriptPubKey.Addresses) + } + + if !reflect.DeepEqual(got.Vout[i].ScriptPubKey.Hex, tt.want.Vout[i].ScriptPubKey.Hex) { + t.Errorf("Check ScriptPubKey.Hex at output %d, got %v, want %v", + i, got.Vout[i].ScriptPubKey.Hex, tt.want.Vout[i].ScriptPubKey.Hex) + } + + if !reflect.DeepEqual(got.Vout[i].ValueSat, tt.want.Vout[i].ValueSat) { + t.Errorf("Check ValueSat at output %d, got %v, want %v", + i, got.Vout[i].ValueSat, tt.want.Vout[i].ValueSat) + } + } + + if got.LockTime != tt.want.LockTime { + t.Errorf("Check LockTime, got %v, want %v", got.LockTime, tt.want.LockTime) + } + + if got.Txid != tt.want.Txid { + t.Errorf("Check TxId, got %v, want %v", got.Txid, tt.want.Txid) + } + }) + } +} diff --git a/bchain/coins/firo/firorpc.go b/bchain/coins/firo/firorpc.go new file mode 100644 index 0000000000..6a9debd43e --- /dev/null +++ b/bchain/coins/firo/firorpc.go @@ -0,0 +1,253 @@ +package firo + +import ( + "encoding/hex" + "encoding/json" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" +) + +type FiroRPC struct { + *btc.BitcoinRPC +} + +func NewFiroRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { + // init base implementation + bc, err := btc.NewBitcoinRPC(config, pushHandler) + if err != nil { + return nil, err + } + + // init firo implementation + zc := &FiroRPC{ + BitcoinRPC: bc.(*btc.BitcoinRPC), + } + + zc.ChainConfig.Parse = true + zc.ChainConfig.SupportsEstimateFee = true + zc.ChainConfig.SupportsEstimateSmartFee = false + zc.ParseBlocks = true + zc.RPCMarshaler = btc.JSONMarshalerV1{} + + return zc, nil +} + +func (zc *FiroRPC) Initialize() error { + ci, err := zc.GetChainInfo() + if err != nil { + return err + } + chainName := ci.Chain + + params := GetChainParams(chainName) + + // always create parser + zc.Parser = NewFiroParser(params, zc.ChainConfig) + + // parameters for getInfo request + if params.Net == MainnetMagic { + zc.Testnet = false + zc.Network = "livenet" + } else { + zc.Testnet = true + zc.Network = "testnet" + } + + glog.Info("rpc: block chain ", params.Name) + + return nil +} + +func (zc *FiroRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) { + var err error + + if hash == "" { + hash, err = zc.GetBlockHash(height) + if err != nil { + return nil, err + } + } + + // optimization + if height > 0 { + return zc.GetBlockWithoutHeader(hash, height) + } + + header, err := zc.GetBlockHeader(hash) + if err != nil { + return nil, err + } + + data, err := zc.GetBlockBytes(hash) + if err != nil { + return nil, err + } + + block, err := zc.Parser.ParseBlock(data) + if err != nil { + return nil, errors.Annotatef(err, "hash %v", hash) + } + + block.BlockHeader = *header + + return block, nil +} + +func (zc *FiroRPC) GetBlockInfo(hash string) (*bchain.BlockInfo, error) { + glog.V(1).Info("rpc: getblock (verbosity=true) ", hash) + + res := btc.ResGetBlockInfo{} + req := cmdGetBlock{Method: "getblock"} + req.Params.BlockHash = hash + req.Params.Verbosity = true + err := zc.Call(&req, &res) + + if err != nil { + return nil, errors.Annotatef(err, "hash %v", hash) + } + if res.Error != nil { + if btc.IsErrBlockNotFound(res.Error) { + return nil, bchain.ErrBlockNotFound + } + return nil, errors.Annotatef(res.Error, "hash %v", hash) + } + return &res.Result, nil +} + +func (zc *FiroRPC) GetBlockWithoutHeader(hash string, height uint32) (*bchain.Block, error) { + data, err := zc.GetBlockBytes(hash) + if err != nil { + return nil, err + } + + block, err := zc.Parser.ParseBlock(data) + if err != nil { + return nil, errors.Annotatef(err, "%v %v", height, hash) + } + + block.BlockHeader.Hash = hash + block.BlockHeader.Height = height + + return block, nil +} + +// GetBlockRaw returns block with given hash as hex string +func (zc *FiroRPC) GetBlockRaw(hash string) (string, error) { + glog.V(1).Info("rpc: getblock (verbosity=false) ", hash) + + res := btc.ResGetBlockRaw{} + req := cmdGetBlock{Method: "getblock"} + req.Params.BlockHash = hash + req.Params.Verbosity = false + err := zc.Call(&req, &res) + + if err != nil { + return "", errors.Annotatef(err, "hash %v", hash) + } + if res.Error != nil { + if btc.IsErrBlockNotFound(res.Error) { + return "", bchain.ErrBlockNotFound + } + return "", errors.Annotatef(res.Error, "hash %v", hash) + } + return res.Result, nil +} + +// GetBlockBytes returns block with given hash as bytes +func (zc *FiroRPC) GetBlockBytes(hash string) ([]byte, error) { + block, err := zc.GetBlockRaw(hash) + if err != nil { + return nil, err + } + return hex.DecodeString(block) +} + +func (zc *FiroRPC) GetTransactionForMempool(txid string) (*bchain.Tx, error) { + glog.V(1).Info("rpc: getrawtransaction nonverbose ", txid) + + res := btc.ResGetRawTransactionNonverbose{} + req := cmdGetRawTransaction{Method: "getrawtransaction"} + req.Params.Txid = txid + req.Params.Verbose = 0 + err := zc.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) + } + data, err := hex.DecodeString(res.Result) + if err != nil { + return nil, errors.Annotatef(err, "txid %v", txid) + } + tx, err := zc.Parser.ParseTx(data) + if err != nil { + return nil, errors.Annotatef(err, "txid %v", txid) + } + return tx, nil +} + +func (zc *FiroRPC) GetTransaction(txid string) (*bchain.Tx, error) { + r, err := zc.getRawTransaction(txid) + if err != nil { + return nil, err + } + + tx, err := zc.Parser.ParseTxFromJson(r) + tx.CoinSpecificData = r + if err != nil { + return nil, errors.Annotatef(err, "txid %v", txid) + } + + return tx, nil +} + +func (zc *FiroRPC) GetTransactionSpecific(tx *bchain.Tx) (json.RawMessage, error) { + if csd, ok := tx.CoinSpecificData.(json.RawMessage); ok { + return csd, nil + } + return zc.getRawTransaction(tx.Txid) +} + +func (zc *FiroRPC) getRawTransaction(txid string) (json.RawMessage, error) { + glog.V(1).Info("rpc: getrawtransaction ", txid) + + res := btc.ResGetRawTransaction{} + req := cmdGetRawTransaction{Method: "getrawtransaction"} + req.Params.Txid = txid + req.Params.Verbose = 1 + err := zc.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 +} + +type cmdGetBlock struct { + Method string `json:"method"` + Params struct { + BlockHash string `json:"blockhash"` + Verbosity bool `json:"verbosity"` + } `json:"params"` +} + +type cmdGetRawTransaction struct { + Method string `json:"method"` + Params struct { + Txid string `json:"txid"` + Verbose int `json:"verbose"` + } `json:"params"` +} diff --git a/bchain/coins/firo/testdata/packedtxs.hex b/bchain/coins/firo/testdata/packedtxs.hex new file mode 100644 index 0000000000..11acad5433 --- /dev/null +++ b/bchain/coins/firo/testdata/packedtxs.hex @@ -0,0 +1,6 @@ +0a209d9e759dd970d86df9e105a7d4f671543bc16a03b6c5d2b48895f2a00aa7dd2312ce0201000000011687b1470de50d78794fdd86d7d903345f4209497235da14a03646b0662d3a46010000006a47304402205b7d9c9aae790b69017651e10134735928df3b4a4a2feacc9568eb4fa133ed5902203f21a399385ce29dd79831ea34aa535612aa4314c5bd0b002bbbc9bcd2de1436012102b8d462740c99032a00083ac7028879acec244849e54ad0a04ea87f632f54b1d2feffffff0200e1f5050000000086c10280004c80f767f3ee79953c67a7ed386dcccf1243619eb4bbbe414a3982dd94a83c1b69ac52d6ab3b653a3e05c4e4516c8dfe1e58ada40461bc5835a4a0d0387a51c29ac11b72ae25bbcdef745f50ad08f08b3e9bc2c31a35444398a490e65ac090e9f341f1abdebe47e57e8237ac25d098e951b4164a35caea29f30acb50b12e4425df2880faf633000000001976a914c963f917c7f23cb4243e079db33107571b87690588ac6885010018b2dfbadb0520e88a0628a28d063296011220463a2d66b04636a014da35724909425f3403d9d786dd4f79780de50d47b187161801226a47304402205b7d9c9aae790b69017651e10134735928df3b4a4a2feacc9568eb4fa133ed5902203f21a399385ce29dd79831ea34aa535612aa4314c5bd0b002bbbc9bcd2de1436012102b8d462740c99032a00083ac7028879acec244849e54ad0a04ea87f632f54b1d228feffffff0f3a8f010a0405f5e1001a8601c10280004c80f767f3ee79953c67a7ed386dcccf1243619eb4bbbe414a3982dd94a83c1b69ac52d6ab3b653a3e05c4e4516c8dfe1e58ada40461bc5835a4a0d0387a51c29ac11b72ae25bbcdef745f50ad08f08b3e9bc2c31a35444398a490e65ac090e9f341f1abdebe47e57e8237ac25d098e951b4164a35caea29f30acb50b12e4425df283a470a0433f6fa8010011a1976a914c963f917c7f23cb4243e079db33107571b87690588ac2222614b354b4b693871714462737063584666446a78385542474d6f75685962595a5670 +01000000010000000000000000000000000000000000000000000000000000000000000000fffffffffdb65cc202b25c3200000046551190596d29fb87ee282c1e2204bee5aeb7a1b1c1c28f1d507ca1b5d4f4a351f4af3663d653f8b1061fc77b2b7f72c168414574007b360b3c59f2dddc39519ec1ab30bf290181d1dcd37f4a1e35a24d64937a05be7efbba8c418fe877092be132ec83c77c4098f059ddf947e1aec7e64022acc17bf8cfced88d37da3cb2b2e0105c555a26e42f89f842b219d60ef390a8e998967adf46f06900dd42059810b56112cb23660ed591f4de1eea034fe181a6b1a8285e35212cbc3e0c3f29a138ff6aae9c91ea7abf4e20ce2dd27d7182696963ba53fa57d1eaceafbef2cc814d0b17b19b560a48cfee21fd69025902c23b8ea9fab931a60cf041c09418560020d47a746358826da947e16206a1d35d9879a9d785988bf300a1ee6641d12fea79a3991102d6d8f9b628e5402b0c357de333f9d752df7288ae0e8a60ab910694ee28a04889c52ab6eabc8b890c93fd8129d211357013ead3a8603be4843460cb25856936078045b5b07d1e2570fc2d0f45341827642c3a725a86e07352b2b8f52748e2be7adcfadde26eb9508a93fc5305551b9fda4fa819c1256d868c9b01857bc3a5ef1db57b6351557a53c1409425343abc40754cd121920eb99c92c711c730d838a129b801b2b152ff3b940c83c70addee716160951503eba21720f9859454cab7785cd7f25ecf3846cca6e6c92dd993268c268a3cd1f3d3c3818687f50f5423e658ebb7afdf3f6de96baf2e61b344103c2d16f20e31873d30b38e4a19856a8f510f98e74b819de5f2d208ede4bb3066e8a91d71f4a68f5901755a5faaf54a68316a09fd835f495018f2455f01b6470f8be72360d18baec83e89ed5064a87dd0cee41f57d09f87eecc3dc012f4d2d316544126959484d625a7922f288e1699a5b5b672c44cfaf1ceefd0b4683b1e7a62e9a33bf32412f1a49f1f8a0570dcfee53b9db948e35b9cd545e74e0d024ceb04bf726fe3c323ce002683447beb33788180dcad0a15569e968f185b907b24f0a91a00a237d92a5c2be6d752b27e06fe7238987cf7ee3ed0415a1cd0cc69b8eb586fd6f7b83e01692d9d28b59b9c98c231eb38165d42e62c10cbe4246bfba35cac79f0e002fda3b06941f4ebadba9109d81355ca6d9b0ec463ab4f41542b9cdacbc3c7303b66e5ce54fdb33f1a4e12d069a3154df189ce2f7340d95433de251da4ddf967e000fd69022b80e7bd4378a9be93d9558d63c8b2829c80e9ba75e4603bdcd45a9e100db330dd8017a00cf3d317c770b6d6dcb05cb2cace0e296ce2e8a96b71b0b6ea48be0e2e81cb66e76713a5877020a98acea1230eed97bf80b519b5dca15f724dfc754fd3150d2056ff113c9ffca161e13603f0acdb311614a44a47a2178f46a2017e73fba20d07a1da0a9792080875aafae252a7047154ad590aa34242cc5a76c2bb97c6e1f464d65abb5be84c64589496449f08d066267af9bd40ac5b7b55160f1d2f9933ceec99b3b5a4915776c7d1f5dc2d0226c0742e0c5376bc116aa571cbb692fe53e7bd9c05aa8160d8476d40f5208abf58bae2508bdc5e52ec25fb3a037d17a162646bcf82b6c2dd8560ed86c9a67668a8ade7cce1540d7742400e05d091058fd60396dbd0ac83b54134d64f76303f022da8765a67bd00a0d178a1e97dcf747551decbae17c89c2db17de96220a82f5364504ce7114794de930a35648fbcaeabaf06a329e8e0c3c87f2cae56134acdee0d86b3941d7846e6bbe424e89d8cff510057143547dff7c06ad7326d5bed5de75ec34b3163c3c58a96cca18afe399cef35341d588ff9c15c0c8f5a5a63727ee52311e3f28e3536292ddceb48018b6035113cbb3e838c668b2725f12978e5ab9d8f808dc64ccc0ca48a02c2344e8be8689740c60cd58159e45592c55da593f5f52b1d370a5d6fc364f03fc0ac094f528a67503cbb6fe49513db62596080b728be309f4ada27ead0923de2e89ff8ccea5a00c74f7d106928214e2feeb4ca2bc475cbf3bd7b3458f4d10db64c9abc350e244922519f2d13ddcbeea3f3b2e366eeb00d9d989142faf860823fb5fac1a3e0a72a102c69bfe4ff00fd68023299eb15b9c2892d691c8f439064db72f10d485fb32bc10bedf746bdd83e33f6a56978f66b0f89427a84ffb3f2521841d75a1ef262fbad0547a76deea1151a71b9a39f0d1c8df6c0fa6a66136daafe0b4a205f84df8edb19db8cc069aad6605178c7dd49e9e1af87de1b1ede3fd1ceea73f973ece91ad8ced139754cca4cffa5597bb9fab5fab3d836ee0e04c1ba1077500cf49543bbe5c986a8194b9cb5be63721c4d597c7082d456b23a20ad036c21f416b970a344305217f455925db751f52b0559bd986dd35192f639ee698c9468ba338a7e46ac9e50368eb86e5666af8431e7ae273e14d8202a557d93e3a93cbc1261a4bb13898c9fb15ceb3211f6f7d7adaa30b4baa6c4fea881b84c43f4ee2b9a9111a55fd502fefd95501dedffebebe4fca78fff7c6dd70e90adb7b8f2f611344791968aa3a0bfa06bc759721c622c8f2a4a67851c2acdd586952b84e287f086f60540934d05faf5a267f4ba3f6c17eb15c5fe6f302094247dc9c3d1d42a0017ac8e97400361c94f01c398ad4c9c3f88e21268203e3b52086d796a7147dd039329859e618f7054ca899219485c31bbf460a1b359df1c3a025bff338a365f33f48f71763647e48cc24472edb962d435afd64f394ddab6c6f64e6f54a3568f38ae45ce599fba9314f121eb1c6b8ad3e5964557a058186829a12002b2a9220a1ab55ff478562cb333ef6bb69d4ed4dffd9ebf39ca15f5eecde297afbfd7061e17eda335cf7212389abf1fc13053298cbfd6aa6402a323d5051947347e9fba76b059206a916a4ee84ff1f48c98d9be5ace61a2fef441c44587bae69770f69567ee8f52cd91adcc76250951be53462207cf27746c225e13c2164663cb0ace257902fd5815b878e4f19ff10499acd3700828a051f8c1ec33d421135089001547dc1df5cf9a43da6877472c6496ae65ec1e7b91bc3494769a03cfc6e350c588de0045bf26d0b418e08ffdae019bfb19f510e0e530d66f8173b13826b1281575a5aa703bb86cef598a99b9546e1a241fe86acc5a8f7156542fba23ff41c1db9267708f44dbce1f75465a7befa3e135393b1d5faae4f7d90c480656b0f012d1a66a03c76a58754b22e42f234de46e7f4f05192dc734f497d7d9a1989d657fd1bdb4e2379e4f576c5ee72be808dba602fd3501319e81fe1211176143ac5d9b76a06951a6a0413db2f4ae33d0f7d9a216fe8a5c5828c5af6778cae6464dea07262b1e64f18db9daf24fae038494836e7f96f8056a42f5966ac53f1e3bd7e2a39f129ded3d223908e64e020b7df2fdc275b993ac951921549d0b1cfe6464e8a3600f21714108f5c1aacdeaffd3416e28db6321b761f973ed338e95b559ae9ff6cfcd65e62d5e92b72cb244dda8ab5babaea6b992d7dc5ddb8bcfd189b2f564de4b57e03016f578c3d0adf004232f2f2ee155af2d6d0224799732c61513f10a51405be7b07ccce65f99f0eac9e3ae73a2782e34226508fee3c4effda657412c2bfeae4e4f2b63037db545bb7353b69654dab3f5da6e05e6c801828301e705eed65de092fc7081807643d9d3a84c2c0f00e460e4a7803f8fbc60c1803783f2a2c378e07531ce57bbb700fd3401139803deba8b83a31f7a90a52292c7b44d8c854a7dcdb835a2ee349fd4034792c0e62fe57a845f2927a74f363bf8f01a8a34266c8c3901c32b69f954e08e08e455f19775d92ee0114ead8da754f4403db89cdbf7e2a26d5560b060cfcfca049fc0b4b6a284f3c8b2ca99b0a53e1fbfffe5375cdb81242e758eb5fe13482030b78cf85d1dceb18833fd999d7f2b99a59961c12b8cd5e7cf8b0aa0212334023a28dd3a1211961fc7b7d8583a35d3a89b591e085eb2c63a111dd5ed4fa7b940733658a17e4ebdfb86a9132803d71a9a8b999fd9084a309214eaa5d12c6ade1d5afecf98cdb590d5d67ad79523ab29343643f9d6fe45afb34db61d0d7575f3fa21eac819d3663c5c868b32c0b5fee74ca11dc907de348029cc4f8b9db1008defc55f5f2f7f161d8249f5a5c4e7b643526f176d901a50fd3501be7ca3cbab1bfafd3e532d3cff08a4e43615ccfe9b5c75d661abb778188b62340f9a2f91c7b4e8f921f94fd023695364ce23a1a128cf630a36e69460c732cf514bb3a6512b23878d36505dae42b2680fb5bd293883938fc4964ce807d00a3d5b5bd93eb5328ba05c4ece7a62a6ce579ea0301c8cb04f359d93a68f4752de9641463fa9ae07d1b8ea2c21015539f5687be2977116e4ee99b1230ced94c52486e6ae38badebf88859df164e18ea343305d7153ebf5c6bb8fbbebf3c47cd23411961558edf12b57bf180819412bcc84fbc999fea2535efb01563c48313f12f3f42d3757c5da59e90948878b64f868be2604f8bccc4d103868ad3c9c346049a2c66c590067b890993f7de9b8b229cbe55b7d9c0d3716bb51c53188175fc7bc04bf4b744774ad7dce79d5bd21e4a4c294f8201c1c081602fd3501a925334ef2e47c0890a6a542f8321eef345b2cfd931a0c48c0296b20c1a22f741c3d7a133756ca24ca1455567fb99b6b6da19593a4dcdab7304b5963850e3b79442602217a64245cac37b1aea73afe494057b545324279d70041fe2977232b8a04ec926664ea4c10feb022da5e3ce3ec5a8725192c3d795a614dc479aa0c099f19d13bc97a30cf1ddb36182834deeb42e89b65a6b76cd00b934bd4bacbc9d7aeb0f544059f612d1c8837ebcfc2491fc5e9f1ae8a4b9f08d9877801b8f18c28da4bbcbbaeb8362fb18f6bec531557cdc5231f6ebd4fc73f97eaaeea338c62796b05e0b84b12c8c8de7b0444edd0420c2e5dfe1e6fc5a0c93b7e0ab7f005ae536e9b30a93679b9c5425aced70c1d60ac61d47705744e88b90697694a6b6f32a5eee6b60c4f96d0cfedb03ad96b8172aae6441e01c100a491037d637954ace3da0f416b9364be62df441262e33883df3ba56e9b6f665dbda14a45434e22edc692e0ef977f3d1f902084a3342833ac2ce396859131b64f0cd73bb1be3c22c99fc91dc3ffe07862cae7a34c4384d68d4f729b1b174d55b13e03dfa1fab5af8081d61291da97fd2a00762ae441ee631e242852bc20f5ed8b62a6e4725d977c66b16ebf4daa6511f7070e31b4446339c44d0a90dca22fb29085f2e02884fdd40110ab9262959ff2a85438df9126d869e3d4f7b85044344d4067c7af01979ffcb5598ff17cac8d6b588d9f82d87b8f144bd16149d9277ef00a79fa4d80ea97e7f7e7143246addf1e15e576789c0ad716c44f244d46a02110d413d456f8eb53da3d36589cf777172c14c5d3d56cb7d61471c0a6b22a6dd9f5928fa018ef0577c8dfd5cc5509da86e2a62cab87b5e757e0fbfde1cdf19edccc2d78636ae3ebacf75dbb1121c52ed86dda072db87ddfdabbcaf9b39fdf1fdc072af586e1a091fe00befb4572fac4c8fb4f9ff5f85c13f66f238f4f287c2e8e852729a1aab11188a942d8db8bb8e6483062c8e75166584e8ae11b6685026f8145951f6ac8ca9df676ce965c2f226e5d6c2cb482fd067f50030495d5826cf24d36516ca9894ad2303eda071956582eb6a60e6dbee56d472ec998b3dd3c5d08cf73ced73a7750c2936e23836f36e68544a3b7e02fc576de20e0a76fdb1c13fa6f4090bf91ace61373ccd5e573ee262daed75739f435121df7778313542421441c131cee9cc671fad72b2d1bd5748e6aed813e80f75ed6497522f75f1351ca859a922d1c122fcbd532c82d2a4853a1fb2ec698113421b5d6fc9dd429408c90051f8fab28f03cd7a86c61aefb1b1a833676a33df8ec52b3f697189db992758dfd580115f27596d43332bb625f4cfd5bd5e5545238aa31cc9b706d921f4d8b9184573b9249e3aa6d1d182d86c9a6de8f9b26b71d76d67cdd3638f2c48ade2b47dd60a95d119992c232a14ef05e053601c2a178647da59ad43eb5a4be732e1b8792d8a1d7d9259629ad7f882120b8f4f6984ab464183796bf5980d05bf32d85f61421ca4ff3dfd9c94c5dd3b1b33a0e3b113ab1dda8b2e6fe0daf32f72164a940c9dbbd9db8d460ea919e3f8338257f77ef3e884eb3254b5f60a92e0913d741acf9c173e92e3c0da33af70020649c004845c03018531c5394b3a53668b81eb539981c310270a3c7c4ec25567955eba73d9c37af67abab999f2bce0e14e19e835bda0cc7f5c58851fc4079f704ff8575d44e161f954e835e39ad1c5f9e2a414f890fbbdbfd1a50a1c73fd72ac36e4c2668ffbec8311c76a94340edca158d1acc2c0ea90042149a5b5d198081833bc3f1309fbb7cdf34de6e5dea2b04452f18f8714095ea9c9ab37aa003337a5c5c44a315d77ac8f7e35983106ac5ccee6c21534b87fcc7969e25caf720a6eb4b63cce609aaeec0dc0592340efb93ab426320bc035cfd5901f2ddb66c64b1198d80e619cc73ce127e86ddc9df078d3c71671333c7dad2f0089c65e83070efb0161a3014706337436131cc54e43f0e3484bf24661897bdfc34e64af6d49328f763c164c39e9041cdd3ddf43b1178869d9e4cdebd8e1592acd581a5402f3482c6ae63b34246592a35e9e220055f93c06f704b6484fb7f1b2eb0cc5e587cfa4d4dee683c3d412f4593873ba2191a218d5aadad29d7bea522307be7979158ab102f3e04329846f02793b775c271e7ab66c1d8582e53a2496a438188fde722c48e7f6bb6e91000b05c1553407622bfa2a9fb146dc169b163130baf7802ecbdf0bd059f32bd1a4549fefc9a3a03a99449c9cdbbd45206244fbd9792a69036e8eea32d82ac89694b65887a48308314c0efbf408c689d119ad46ed237c74c322407cc8d499c49bc454dd090802ffc33eff180ca0b3968b39e0df7f8b259cbe95b754ada17686e1530b0a702bca93b1ca42529d68000fd58013c59ca9ff207c4a2d57122e6c374b0c8125176b534bff226a91d7bbea935a07f8602c06eea81ed5ed388524c7a3fbe0dd4c850687652dae368a48bc8ce91711ced188b7da9a7ef1e7d8b96145b39faf8b2e95376cbd173bdeda632b792296dff0df80d4cb3e30fba1960cffb3492159938e0b61a632966284666f50223e3cd14bfb4cc1e95a707677d0ec770751860411b7fe90f4e2c078c11298ba2010c7410594b9de7e6fbe80aea2cb76f8be0c0572defb9d58cceb06dc1c84e197f867452e6a502bb7e0c18d5b1ec9004315563750ccefca4fb65aa1a51aa32773d6519281b7bf6ba826be6f5403b549c3e3646ddff159376c534fcc1e7e339af2ade2e992949d6f2d6362e1c26c70e60ae9669a3a73702afe1c06684794e75966612e9d99cbc7db18acb4a3f37baa1ede7bc419cf655499dac0d126ac3ba833e4aa4822c7bf2c49ed8d94b28055168f4ac738c042b6f21b4dd779539fdd4013688d933c2502cdaed2b4360fcef5c8173ef2c1f5a91604850ec2c81e706d1a2b0c87154380186b812304dcaa7363afe5cb6a52ed235690d746f1a070445fe4ab9a18df19f0d1e87b1a2e9bff724f6c77e2cbaac74a7694366f16620cf4a1d73e3fac311750c406c3fe6c5df4fa5d996d92673571550d694b47b69383e6251171010e3ac21f01f12fe2c764374a3457f34e83ec0c9e87f182f84bf72f1595714c8825a720545a865f223cc3863cb5631c8224bbbf3e082b2c07da33a0b180acb89db94127dbe3c060ef10a8b32298c153aafb1870464eba5414846330f5f274bb6b87e4a2613549853578b7024a249351fc54079737859c559ee066d6186ef6a06a94c19318ae8fd119998b8b8fba2990970a73ace570ae0dfd6a4976c7e240bc1224a410289793d0a97a71b6c60143b2f0163c69cdae4c7dacc707eec9d2de6820b47a6a900aec39f0157e729eece517ce5d1079f88811c6bd1647d32b1375eadd5bcd5b8ef6e9e05b79f4e9fb2497c2d0b1e886ef68b298af6421a7b527357a3cf8a10963d5503a0ed1355ad8e003abd987fb9fe9e26d919ffece2fd1f00fc87188e2a1fd0cfd122c58fab58ba37a61312c68f641908df7043b1b65fe52707eedce969a8a8dd245eb4694e9d01673b1e441d81609b0a91c4ae4f779c7b1838386632fcb1f1dc90d74a3920741c4c0c3ed4ca4b61a0b12195bc5e16f7ea637a38e63f52d0aeb3e4865d1650a2cebe2c14c5a4c2a155975755d0cdd2e65f9ea0dcbde187cad3a88544e0d9b4a4900a590d5a44ab0121ae1f4ac2eb65b5eda140899d5fa527deb95ca4176769f96a68ad3c506723860b0146eaa4360b738ceaf67292a88f4c15f5c91183fab11fa57427a87ccfb1b4214b44c0d2c6d9668e4abe6e4c43934934eb5c621d5b097508411896b343eab7a5acab87607386f907608f6bd3de45fa08183e01037f339cb3905fa8bdd791b8e7d9ee54fc2e424a1537f63e48ad2420d219b14c7025e7d32c0292867d30c023d3900e6aad9c768826c86467b1ebc2ef86774427eb433785f7b5d05db05b056195824d3e40bc2785e40250206fb1680814835100fe5a77ba4cc5816a80b1edd12ee960fc9fc898cc6051d625206d1663c4aad291b5a8b6f9aab95a0e60e9f12f3693f46958ef0fc5ec460d4a5121469a59ebc1b20742c238592976434be70e9406aa2900d31d637dc65fd2de61a80021c54f7dcf90aba4912a73a20038a951127348621ff65add2a75feea07162e63b10021ae0dc0278bcbb2968e8f6f2fa99216a614adcd38433b32b5481ff35082e6f19f002060b1d489bb9b3ee9f5670890d8bf329bdb906955ca9c9b1e23190c4af9b9251320f59505121fcf1a53150766b2b65e55e2b36cc7fc61da94746b17a9b7f97df86e2076dcbe98ccffeac440de898fafa058b7501b07691431b6d32ada652102d55b2820974a8dbc563de8510d65da16fdf79575b59fd2a490177a7f5bd63ff03d48a554201a31ebd30e8c013223a76725afe3d50caa5e1025925a4c03d19dffb17f5d175320e1bf7f439ea8079322a86024e1253cd71604d458c67e09929fe89394402d165020be0d25d6e004b1f86d249a8b4b9e06b5619d165c2057aec4c4bee1a0ee4eb240217beb32c9e29f2dee1bab88aa620d7ed7a7dae80d04f03c1c17ca78e1a9c803b70020b7b27036b274dd398eacccf27a1f8d67fdb3bba2819c5ef0aa94b7c3995464a220487edd3892385c68e0765cf86ac7379a6ba506c3d687615dfd1664a61e0df10620f6e44766be42266c3202569865c8341a8b4a9445769ba336cfacd7b8141f9a9a21f1e28f7a220f0caf78a9ce7a4524d87fb1a8cdccfe6dec364d94ebbba6dd93b2002115b207a64913e0303ec3915a67279f85002410dc25184f06a03b9177f3134695002022c96e73a8fbe87b7755f8f2181f91b5d5348bc861fd6ab35ee71b4ddc5d8a1f210837a3775e5e598150999a4706ec22526e8321f73f7e78d0693595aead84128900219538d13c754a2ac0f1ebbc737d7bd3a4468b7e91636f10bbd980d8253ba5f3a70021182188329afd23ba2916e46880a016b493538ee3ffc4438488fcf6a36d78e48900205add8ddabdab1dbbefb5c2439ff789e158197076ab6b8d99ab37ec4d23e0151f20c876fb7a9976e7b0e8b4fa2d40a26a1f88b5203a992c71f86c863f64409a6c9420ba46a5a64b38094cce0e477fcf526a371f81d98758305173ff85e5af9e9d713520a29a3995c535d10d2de254ce8dc6fdb52a0e6965d5faeec07548aa6b43a91159217f1341316ff39e8dfaffd537063c130f3dd19770d2b911eb407f1c05b42e398e0020d2b3667ad2def5e59fc37b22e196fbda8d2c41b886be1f3cbef4ba78e7fb1b18201d0e660c8294d3550ea90d2e976f0263209275ba6e277ccbec9daba6d361286c2028c77b1955f5cefdec1e35cc2e9121d07651200e90184d7cf32f40dc73432c41217769836ae0d553d95a53b045352d122ac2c489cfb66a172346a3de53801ca99e002094543d995a9f86fb5f49c78fa23d0868faeb3bcca002fd7604fbf81f38c44a712137ffe7281b281b17aa5419276642b8e69eb1b1eabe30ebbefebb022c21f268a300208092e548789ae3e160dbbcc8ad981f80804d9e485003a6c688fdecaeb277b500202d5b57d5d18194fea324bb7c742151f84f9fa7fdb69fac77ed936a56c80cdb5520015264325a4159703b2d38af540c0e680ae700f3b9bf3c069a80696bd322a95521ac7f435a2907331d8dc15dd9dc945807e3ee5ab5295bd574483300431612edbc00209836c6b63d43ee695f135717c85358663d39944bab412134cfd66db5762c9a442145dbfb1f0d944e7f7b6d4b3648659b3b12a4a2c53bd72f9f65e7198957db9f8a0021f8fa17536fd1f70702678ded21a1c7035ca8f088961c04af7c7a4a5df96f0ea60020b7b386e088b3bbb85f1840ff606079ac9ea7f9d0beb62f5c7c5a924913df2c4a20a977ef35ba6c8f89af4b16d5903a1f0d005982c2826797c6fddd0cd2bca1b94c205c0b1932340551606bc9e2602bbfaf633de59ad8fcfe19c4050dac8c664937312028b3e3013ab25c7815169231b9b724e8ae2ca3bdb5fd17487d1fa39046cb77482053b98d7674de0cbba37c37751a7adfbc9c0cbf1b40752921a7d91b08e584fe35205372487e10cc1f1e2d524bb76bc4422d97602f7893c62d28ddae4fc9a896d0372067c5cd6065fa02b76a852744f9cf0b97d32a14ae4cafc94a52087f726693e13921963c3293684a500a48267f5579e77eda8f877d15e4911936d0f8e74b4d38d98700208be980fa8c412eedc13df4b6231e3d2b564296825f490db1e2eac607a355113720397b07219a89c803defc3fc3ac5fc258c8b54b39f53184ee13242feb50a0c62420223706f83565ebce2acd2c18f4cfa79edaf67508da1d2472bfe325e5f20cde47213dee7fcac92a23e8c1c1325e6f086d1c8cd27e47535899399c6e1e4f8784f0bb002014a117fbf97976c0c7af3a56308a4dd19abf6f6a7afb4238e5cd2b41ff3d8b5321bd2e9d0964bab0a1e554eb0a1b350928f2810c4fcdab5ab4e875005cb4a9e69700200e9fee09a4bce859bb38e62a7c74941cb0376d118f1738f06b8a517fb618ec7a20f90381d08f1fa4eca24bccd2e979d0f28710375da371378f74f991439ae08132200b7166584e050832e699ec020e5ae55f07fe8ae4ba7c2c399ef302fb1abf064320eaf6573fcce33c66ea0aab58eef64a3efc1f637b738ee51a95b162eaa9cc476a20e6540cd1e230afdb93aebba474c269c423facf47f2bd500e08961f7c0a4af55320a8fe890159adee60472ed604e73b725c36b2e0a1dc9dc94138a95ab43b38152920d80414b480db1b23a83530d76b6ba4768b612856f328c5d1f481c392bd69f670205cbf3bf512e6647b24098affecb63045ba48ee161913cbea137d89f8c2317e18213ca2d715f1dbf2f7d1cd1843584cee3c6cb663830c2566d2375a8b7d4306a7b3002067451e9fa32a3f67f8940b3d5ed7356e532ab64588a30bc64e68bf0f1754eb6921aab661ffb9e2489a080b5dadf8b66a01b4da585f1d60fb19803d7870aab59f94002009a42d8c17bc201a7683473c104361db25afd272558b431c7205c1ad60e5275720b5259493fe51d34e9e9f13cd027324de99208f62fc7088503d065bbd22eb671e20c2a1ce148baeb48bc4074806162c5081bbc4636a01d2947e2e511a8e23f05010217f78c01cf3b1de88c2b5efe4f1a44da7b7ad1d70de3a9ee75de52c21f5d9dda10021b1e53880cf898cc3304af3330d0dc20424ccefb35751124b925e132d89ffbb840021ddada2ffe00b2b281c447b8d03562bdfaad7248bfd3b82ac74178258c17f629700209f39211210bbb04910304087e2907c3a8a12ed4142aaa866b6916b3f17d1c3232113567eae2f96882409da14e61072dc3941a7592b816b25b3d52f3ccf8ba5499500217e8262ee95708b1b40ea9644b6307ff3886fa0159a3e28d6155e3c4f737e2a9000208a5f96711ed5da026ea1c3e40ec96a8c5860e871ca599c9ea740e3ceaab2480720cb25ebb06c94ad22dc7d529f3296366d4f65781e165de8cab751d4fe464da97220269dcf06c230675b405eec3bd7b1e99d9191242bb0d8f089d31f5d41d61e4768214635add2924737b775e5c252b8ec10a1d072ac4ab941d8745ace8db5ca7c72a4002025a5b0916a1b7e739c6926915cbdba2f4fa3b8b2728a7030cca4946362e3e84d20c5b5d7283e047fd80f2281445463424ac2a6f1aaf2053bbc3c136254bbade21850801b49c2a7eeee02072a84d52810a6e308b5b895f082b83827d566722f46f9dcadcc7437e6a5df1f12cbb56bf34473a0bbd93b18b130a8a3b98a08ff3212094ecf6309aab5bb96fc39e51df0828b70ed423b3ea325d175f412bea1f96c89ae4459987ad12891d24e968ddfa4f4c00e2fd4ee2d08d2c0e6ad48129c32fa7bc99c3681e3f7996a1b93387a10520949c62c2c64a0ec1889c5eb5c1313291a78dd7213244c21eb9a9da1b77c9ea77880305bccd24ffbbad2883c52dc411485b64a291bcc1440f9eba8277d0d8db1ebc00f874f52b126e99fa1d1ea5174c2556085a46a0223466cdbc23a9e217afdd1de8a60be75e11faeda6091a37299745789b6ade6800081d69088cb8d7bb502ead5f1955391de9d7fdb577fb2da28195a81f6902612316ac9f15ae160b2977310cee6660ecdac2fe9f801f9188635c83ae12a89e3aaab5ac05d3b988fb6854f17faa24d0dd9d29d79489ce3d453903f951a6c83bd4c5874482dc6b0e4883ffa65e4a955c45f7fe7ef32f5ec034595c8216cbc62393ea19900818b43280d3245ce70caa22225803eb986dc3353c37d798f84761ef12a56e00ac6dcfb4350a8e6f108b0f10a1975d0e47508730903e94a2ee8d9f36561d1fd2802bcc103367e15e325eec1cb09c86f40d632e9bbde8b2f6006b4981fed1772729c17d1cf3859e4cdefe9246ff6f6285450b520180f04665c25527cfc85da4596bf00804399c22b05bd36cc68e8e7b5c2625bc34806eed211d86887cd37742f1108acf1f06278eb9028eee4673e0cadd2a5e1f5f257422afb0fcc199e65728ccd12fe689ba03b50dde3957bb674b01baad178efa863bcd10de5235f3fbac3062933488e9b4a60b2cb716c5c2a9648aeba59eb3e50ae3be842336355c36231630a918900fd0001c22157003bf3613cb4e60bc0842ef72d03c3927ace3e35f79e7975e6d93593c1727ece0f9734776e3fd8354869dd0c2e36d992e493524f97875a5798e45ad8800d288ff3c5ed1c656298547b3f386690d20d323daa40d684b557ffdc2fd64c2f3f71938ffad426211d4e0fa1ab71bf2eab2095a61868ad51bc622506f95d2186870b9fd55fadcab4734a96bb996948339408559f1ab3d0793b6ff3830c22dcf8387590bfee93005b5baf5890bf9e3c925d40906e714205aeddb42376eda4f4ac7d96bf9a74546ca377bece79b690d870a560c3b1c4416b06bcfba6904392ba19214fe91184b7545019fb8a5c65e0a6919720dd962c91f98992177eeaec4665b6fd000152c7953810a6139a35d9ab44951eacb6d7f88b6a2d0fd1a05cf109d8f9b8092d1e970d6ef12cbfd2f8f901baae01d8830b8cd521e63300bbc1bc623fa5c0e48017333a631d42b0e71d1508e7b8dcc53fb304d4480e2a4e440c9e53204482c72d97b4d8561306d64030846c9027bf218567d607c4a2304df183036f1861fed60942ba64961824b80fd8a828499888f80a11cf91ad2fe187aae73605bff8a4b004a2738d56a5abf11f9b82f8ddd501443545bb4aeb49fe39b64c7a768380892c6f00f8cfb49f4594e1c88ceec1125a3b70e890150dace647307c1cfe715642756d5d2f6c28218274ca5668a3c2a4af4b79e70af8b83de56337b841c94dcef0c89ffd000109c0d936c59e7b389dd374956c37ab4b8978cf0aa5b7050dc50510f381eabac6fa2e91934b72e798eae1f6f43be168ce1ef600e7b9bd1bbc2c2c963cc1c777c41ea9ce7cb85dbb140bb01cd90bef6298783d8c6c056955eb83b7b9df63ba4b9cb4201cfc83897e54e269398be9aea1e293fe7131f92d22b1fe8ecaa22cd934980f4f0e1b8a91dbfbec640010d91623780a2e7647391eadd5a10bedc3efbbdbf189c33057605f1cfee70d8ced664531535abace6d63bcbcd12774d461c91e4c836a9534b35a735f211cfa324d74febc41fc9ea3e6e953ef555deb6ad348e35ef3be21e32d2546006d43765fb7275d10c47618d109fe806a4f94fc67940ee02aabfd00017585f2e215c1912e88aad895e87882da714c625143b5f1a9ecb9764ef1e1a1e654c08c70a2208e371f9c4b2aca734bca273072eb9cf5621c73ed442efc85624c10b0564c96f488cd5ed697fb7ea414c8f49f5668c4b41227cd57df071e004675cfe16914c9e3e018ac7e0b720b9cb9496f2d0e176ab2d611ede6e80ef5803566bf698e09b80a81c0eebe58ecda39093f0c1651fff5aff860c4b2e70460bb95da3a74cae7e26139d1b257ed9aae65dd4d86e240f07ea77f1691be722bf9855ffa759afac8a1e6c91326da71a1120092a914507c2000a167966a74c8e5fa8533078be90087d59fa75405168d72126667458525b6406849bc1bc9a97db49a37d084fd000154e8d56136b6dab9d5fadb3065668dab000eda7d2a8c47b342ee5c95281fa8e2fcebcad5a0943f2ddbc46390eac974b4b27ab9da4fb3747917c22305d3ad91b5694b312dfeb392b55df60cc8d4f6950bfbf4d5dbccee860d9997d2de34bba2335733909110bb273c2e36c15315fb79a93d1bffe33c358e2da4c238e8ae734fda09936a758f0713f720bc556381e41f76c29b7a02bd44926d5b2a7d818c788315c253a90a03b9194fbd581603e03a34bb298d8a6f4021b887ce813f3cccc17a2a7f6bdc5b50a681723890250c4e9050694c9a66fc587187973f209c1962bbc5bc7ff64fed7d6a171981b814a80a1cf3123a8dc622008cfac1baebce0dfbe52ab080209b50f0445fcf9488fe4818e96d8556331f20c211c60e07f1f80e3ab23103281a07c5df8d85c6fa1767aa997ab2dc3bbbf1533ffa8729bc02f6ed3d9ec12441576ea311a1e30af774c92f70f5d4521b1a67d0b7c1571c45e23785e70bcbbae1da98f8e2eeccbc67ec771a30b68e37f8a385820bfdfc7af405bd5375df20557cfd000167f18545407c8f34edfe760a91ca58479b4caaa3964af57568e4fe511cdc94e99919ac76e43c423dd4024457896c2367cb62da0ebe7d8b98cf79981256d870421ef6adafa61bdd61a9fa752a3102bdbe90ec1f9ea1402c855c2a78c5c09ee8a4297dd815aba0b346eb3be92a04301c33c83b0d02ea26a4eebbfba0b71667354bd8e6c825eda303b05207062b3b909397026f469a3dba5dbb851bd28500322b2b898efba194e9c89a97e378691c6c3587f7fe4bf1a3c69d31fb9195ea9ad626406f33bb39e8083452035038c1714d2753ffd79f62643057bff804d7693e014a80a9e32d1db6e9219e55ae5d59ca7f9615b252132a559ae8f0ea9bb70947170fd3806fe1ed3b59b8df259900cb793dd2f745658cb2cae325e988ae4259bf3674b40d952737b874531487ad58a38fd6d01435d4e14a87b0fef4ba40a2c985cc62ea2d3b6c97453c8bfc61ded2026a403939615b94db3ed5388adf92480ebe647d9c209541b9ab97a67f8afa8ba2ddc4f6621eac975806f7a2935a4754ca1281407254fd0001ca584567228a05aff314a6bb8db5d77c64cdc98049e4fc4e8a0dd02e94d65e494a83fd0573bf26071fdfce8455a8586bedcc9ec3912fb93c28c97c76fd2bd8cf7c77eb032f1b3d5f18cacb4d6e46d1d636e5423de333171621ac4ddf00cd140c8a31cfe6e1720b702f5977426ba0f341c5c121fa41e5f9cf72c676d7d8840760047baeef41a85ee0f58650fffa0dfcf4a354b4fd635f65d533afdf68682c062fa1ef3ed0345e0e6a4a03b2dd3fb6c1918fd4c6ea2e88efc1223bf72d33a12ec9f10212abd8e0d323fefe127edc909daf018a59e7be84b92ad9506be6cc080fdaccba9d0e6153e49ebe546afa2c3a5fd37294b035eaeb5a46ffb1020a5fe683b0810df474b799476566c1a4287bbd112cf2fcedd1be2cdb8707c55db9af086106f66a061f2f39e2ea3bfd1cbc18dcc049d9011336b2bcc240f731b3f45955e15d228656e7d41424ed16096607d48dfe0e2456d877645b5ea8f006b797958aad495cf7d57408484038b87ec99653b7fdc5b8a1ebf47b7883218bb9cd52eb8a22e49300fd0001310d818741b56832a1a31e3aecde85d578e6bef95e0d3321278f243dcf81ec7e2e6780e1bd4de223b5835f57184ea2c2edab2b870fb13f620b3124f2fc83740c26aa30b917680eb4a61a3d1e455928a6325ca2c330a74f35c659dab9219fc1ad2dc4fac28a8055bbc1acf272e294b21d1c3083c105b107e9ddd14314926a5067dfad3ea37c54ed50a5ac96391dea5fb553fa689d4166b8547b0af7764d22b31deceb9d8b25bd2edda13de0b952e8c062504896af885bd026edb9708bdb23617f0fc68a726432ea1c929262c82bb2be5f1536c6f88d33b308f8c929560caaf74b8fe5f840706e3e0b81bee0e46cdb134867bf8b11655fa204759bdc88d492eeb18093dce3035bac48a26fee7f6f5ac66adf63876ef22300572f528d5f482480e951befca94ed142ce71d311a3000d7895f2d9f688edd34ca44a68a09cfc4b685ef9f5e8a6d75e956e99a6bd01bdc002c94cf87861518df3a5a0713d7ac072254ac1d68de90d6a521348969bc2d59fbbcdef6918c045701421c92ef733b3b7c4a3678026ef6ecbb093dd60690ab9b7d28280a81598793788de0272eb52423c3b5335c844fae3a69374757a3b41e3cb2250e36d5e185eb2e67950782ecc1d31398965fe54f680c52b1806bd764fa2926377fa6f7f909ade7774b8a91a65049bb6862048d389c3536be88e1800ce95c0ef3477fcb5317ac511b78dde2dee12fe305773188132574bb60a5e68118e2373411b35dd42ca882b7b833c4efc20f1f3bab6cc6ff7036d48b2051bae2ac95dda94cc330ec1d0e3e09f856c7e36c44020b01a5076268aa5ac517cb4c9e936f958b7ac6f8fd67e961e083487b2befc7f923c559f6c52309b677fb090a604a6e9454c2461b2fa1574403fc2438fdaa1318c606707c0c600fd000124940c5f2606ccd649a9988afb6775e60891f95b91773924d7017af430cafcbd0484929b049c7a8372852bb695ce1748bdfbc150a5ca6a1519c06e5982c990e6b22f509a606337a647d9d1643522264b5838390e716cc8bd4f47bb8a0de23577b998855752c434efe432595f63529bda7c7164b321304afaa6a4adb71dc05c25f5e5294b69b21c75a13edec9f8c0a31243aa73ce6592f1bbc84c4705daef99acba57280dc92de02e17f1b28473f200b3e4a8e577312e51f1f79c06ea49f9f1a27eef83ed0749d5eb6534f9d8ce773e94f21407cd17154c644d8099b4edbeebf4401601d3e3667c32186ae79c69abb3c72c0e8220b2ab9304d1307a686c9db992808823b2b219c9f81d5a641e40be3eb71e841db1e43d571d3b225b5d811e9a0101b37891b8a962be19c7b127961ac447a847de4782680d3ced69df0c4f032ddf36d9f7da47aebba193b703598c12c2214dd41953a8fd4c2956d261c989d560d09809e6471d71c5ccefb171e1b84b806e1ebb792b40fba818c40a8ccbd07ccd5301fd00011813eadc77aec30750c84dd27a1dd089ec245bb82d93aed9f343f7cacd9cd49a22a4b516df334c6981cf57d9038700ef0eb610a70bc71dfb1f4d74ae3359835b67090bcf46549a2f8eb5e9d9573d6f2900efa6164528cb2298d488b7ba8df39748f6fe41ae04028fe3e171c68cf7954b228e0e54f266f447d9a93ae944517fbc95d02e898ee7f3619a02abed25e78cdfdfd3ee09521a5a1067790117d5641cde06554e7aff909ad7f7d8f67dbf9fe0ebd1f75856dd0face6d53b10230d2f605b1c1b022376c2f569d9849bf094f7b47e5c1aa5f88d3cba904de9fc2299ce60672c59b6b951ec15809a78e2beb4b64db2768a44d253da8268ba4d6b1517b03ba980ea5c2231b957018bfcb1dcecf26ef89a5338976732dbdb6f7354da85b62d1f0064a9c99ffbc36228487ecd45f4d605bb3a2f0113b756f61bd2d5bd7a75019489fb9b4807f90c78004233c53031f7013ff3fbfe9f37cbd657c61e071dc1e48a5c15f5b1cde2ceae555497228b19d2be443ef59e89067504c76df6197e899aa833fd00016741248c9db871fe238a8fd2a153a21d659c82caa6d0d5597a900979d10862ef7bbb9643b8423a704cdfc787bad8b06f693e0279e399125db92681391a88cd1f8a3b9d620fa3d29071d4b540fdda7d24886beff41e9624955bef1eebb27505487c6b650f941c5487db2d6a9de360fac13754bf6bdcacf8f5162e78e1808c2021468b402d2de932a590a22371ae513e4f8385cc3d54d6d8112d30b5053dee2767bd5d68b1cef07c5dbe79be7a13b4dc761b303625a35ffd50cd1a7a7607c34f76b737cad0a77c991efcc0f48ec0baea05350643839073c4d912d7f6d18ef80f1c42320c5a1949abf9500e5e027f84dd326ddc25b796e885f878be522c987a47a1fd0001892880727994f3ef4cc7bc3b009d3ab0ba12e6fbec400d978a7b094fcbd63826e5a2dbbdddb37042f9d488f82e0f6d64bcf88d327aea5615c6445c13424544d45e12007f26b62408c19eba388bcb27a32b549f048cdf1a9df32817a926ab34e130848792f71cb81f0dc000f5b640972a5d1180b3876e2c170e3ef31e27610e5db6cf50077970504de9b6354284bf12106151876524752dc34024d7c353c8618c1a0f54b958edeb421d5d521470bc1edabdd5106b7f89c1a5d52b36c7491d76d6d52c153e17e692a1ad389a57564aaa352fabff8b65dd9b18b6e76feaece6bd76f09a88d60cc344d1865aa7b97dcd9b7ee5d869943a0aca6189289cbdfd9464cdfd0001ba441f650b1c2d89d985d28731ee44502b189fa4ea4e283e9ccc8f5aa2026a3b761d9c83d2823ac20f780b887d2487f900c5f568f2059f44c2014b08d7886be7d6654dd8c760d82a2f4bc80e06760211321638b0c676bfd4254fccb74b497e866d33885345ef0a990aaf150fc2d36ec3a7a2b21b3e10356b6d7ee5984273f34f295ad3c9ba5ec4af588da45a4b512587181a89d3cf11cccbecaa9a590396b63f27ac3e157a08df9aa19867a7729910a02ef994441cb0a733d957c5e41351f8784776b45246159733c6818c817f4b7f219a68c13dd02b0410b037135025fd5a07f320f44a21926c5c243636bbc3a0f437294bc019a8bc8e9e14795ea712ded2d28092f38d55599ff2c0dd2650de43a589a498fba4d1920cf9557aeef01575efb7139b8cf10b6ea5ab3ef9b40d4a90977ab89c55a5af3fbf0f8a72197abc38dc6d6df406cc7531260a8d5e36d3ec1ddb95486596b45977c1559892fe96ee1ec87d54083c8e88fb75590898be9bb956ad1593009b68285fcd60e29a392130fcdecf3680c4f08fc6aed56784e471ad4dd0d0146e0fd41c4e17d1e660daa6fd01634cc52a48fc71402242ad1b9a1a42f254b433769f44f895ec40119b40a9f731e07d5b5a08b65c2ab225a933a92889e4da908332a35de27bcf88db00ae7f7d4fc3270b4fbe1cddd3934864c12b77d6f109704e9c0835742abcc29dde4bcead8b0c0a1a08fd00019cd781470ffc9915bbffff9b297207280aedf02ae6ce1972e2ee4f5959aa022fe22098b388eb542ca03a1af83a0f526eeafc95b192c2695eb74d1e55f6c61a950402deadb11bab08257124b0ee26ee87e87570aa7c615eed73c12862708012e2ad8775444fda2687455a0c2e79d9c87e2c8b6eabcc3622c1bdf14f94747086ef33aeafb3b282b1cfbfe7e20bd20e57a00cce137c764a07a8f25e96cadf0ac678eb79938cc592b38b299d77d3d2dccf1bc3ad3da77d15bade71085176833fccb4f4d813b43fedfea06496c732f0ff2898fb0a13ccd272a51da2c7e2c92c0b47106deb290f12f1927efd87500483efc37b35bcad9aaac18b7676d4356e9080cead811cd4046723cc636a017890c78502f131ed1c4f2bb1c67f5a3095a1f39362b5b1d769e202127eefb4c36b280265946cb8519af11524abbd4d0d35b83d9516983b7053f3c5a583f7616ffdb271612030cb06448ce5aaa264ffa9dab04c64cb246fb188fd20ab61d2e39695d47564fb8485e003a517b2f6267c9147da7051ed4ec6008140c144235a6b9076e23b97a81e347c8a367d9c12e0d775a378337eb3b78647fee80b99107d47307ae73c15dd22a4210f98a5e4b7ff6ca8ea79286ee5acded439f8633ce730ee68947fb12a323854ae232ce75fdfa99d274926b14f81e93279f5ef42cf7abaf5e4db9dab5e1ae0442e9a4816d9df5fd1c671ffc2ff9886c50ca400807db6e16ac5cb6fabb094429a97f7ae57639537b30b12f634196798cde9c00a85fef093cd983ad3f4d9fa8cc2168a8331f07fbac52c2544defd008d5d31905a6b4f57e2b786c091b019dd4a23167a457f2adef68fdd71a4989921698a451c323faa2870b78f555c3c30a56319393d5ba640ee9b0c43a2daa29c5c080b4dabf229814d08f0c3c7e21b9fa04c76c7d6c3f12509c060015fe82ffff8eaed0caf49974478bd49b94e1f710945a0c233577808d97d435f09f9193b1e7abe8aeacd1f6f142c9eec20d7427cb919262b8a81372af0523fd6c219b427477da7715f7f07d48f890b751206819b8693faf2c8f6b1cd42735ec457051022d063446c58e9e23a940081d24e2fc309cf1390ca944179e6dbcd7cb4e39832f3c8cdec876f8d964cddf3c27f87802eafa33b2ef393e59fdf9028f1add7988eee4140257fd5b420273d9bef73715569b0001ec2cbcc13e70f3be6f0dfae99b504ff2fda3a3bb4974ea056e1fa739eea46d38af1f2cf3ebdf32887e7ecbb570a3633c5e2425f29d24a5ef1cf00fd00016100ce85d6fdca9575406f04fbad2d9cb4d7f6fa1393be3c3d6fbd5eaf7a99a02f42b8c373bdd03f7c76a9de409f943519dabf438788e2d96b34b7743af43a0b01bf8a080615013519a4424a5ebc90cfaf822719f1fe59ae708b726307243bf7cc399be43050e8b9115ddc1cf4c2e0c4b2a6a9674b1b45584d7b4ca779eaac889dd720bc46bfda1ba2af747a8e53c2b3b857e1e5b607ea5fb45ecf8980250056134dcdb481bd915bab49031c6ad7e3faaa58e39952119d8729e317e15e864512f0bbcc6898a71cfa619fd5753a5b32bf98dead20f99284042a0a661297d468445d982d9159c44fa344aa2cde29454c7b08f3ff4671f23a4d062ec8508b67dda681c0fcd0346c255f430aea7066ea78b6571e8493274db67d253968f033f88c42a90f40a8298aa3db289f0e5ec038201892272b636d947f6ae9c6342e5f081db2b188a9d3f50b2e8fb396fe189ec082fa63fefdb33d11dc2771fa1fe04b23438cce2bfedac58a1c6b6819cb7b02fed3d74e8fcfb839df07bc474ccd8ee76cdd35bd0081ea358b44b3840116487e3f85d40ccef06d78c631423996e991df95018dba5db3cd0c639c4e76122db272e4a59cba263a26cf5877481b93714bf9d78b019e7493443c73c5af84cb6e9837b6d809da037a573a6b68cfd8bda5d02da0c50743e889afd72406eccbfb255f957ad00fc76a8c117d182363dc9e914dd3d6cad81e32bf00fd000133a5e5ee9bcd22e54c18d8ac925859144d32339b2bd00c32e8f98b754cdc3495143c425da51b3db805aa3884ea27c2ac815e4f5575491bfff800fb5a3c1fc0120fbc33d53ca941115350be844493da6e3dc40847fab916235bdb3409a356b9528278102b4d96e93c27c88a081bf0bfc4462ae6a2e340832ee0c76aab12f192f58b4fb5fe386cf566a762f83bfbb88d1859a10d08ec78b01536c3dcb69e9a441f9d2e947e898dda40f57fce5f0e5184d93935fbde32cdb750c51d57cf7c2be917fa299a01c41f2a1018d435380632735f9ad2e958cefc8837c21172bfe67a574db3d8223eba4d0327850bd5fff38459d4fb6e00715ba7ee5355605ea0de209ac7fd000112ffd4061db7a6cc8477b6145a93f3e6fba20a368be255f4e435dfa8c746ffeda600b87dc3e5fef1ffb6e917b09319853adea9701d795e244c4937d25e0cb0c62bf4f69168aa82ca022f6a28a7b85eb1e8eebbbaab30a61c785e19f59232d273d936e4ce4b9c62ebb3ee2b96e90a5a4ab633e9b3704fc0f50ecc5b9ad6f3843576aae92928ca7c8d09e3b87a6281328a1482bb642005cec7dd57f3ef9b1a167387511eb339412c109bb94b57c4e46e3f33d6fbbdeee42e60ebc9f44f7530b86a94bd9a9acb974c76782e954e295770716cd216b83036fee452fb2f83ef077d673f1126ed412c8d9df216b0cbc72456ec8e2932de9539cfd562ada45a389a4d8f808cc78aed67e86adc2cabd1bdc0b21969cb52b9b1f1a1d808d67d8e8bd478f293d9b81bdd65949f5ea0bcf48c6fd5995ec992a273f6e335e7e6969d008590a961240af5ae24e4410dbe1c6dbd001f77406731348a0dc4ce2f8a683e4fc7a49c659207ca2bd8fdcb31d39e58a8c75f76031d5b65d96f0f1af90da9306f019332558135064b7f64dbea34cd68c039d4dcb703f63a0a1effa946cd1c9bed59a956cf85b358f4db3118070265f8aad6c540b066f29852e005003666316066b324cb037b9b5fb6ab8b2908b1b5509e9e0ece6345cc571c84f83dd0efc128ff27a1b5dadf0a0921544e9490d13fc82df1939382f1b6170e8ca99b38362228da418dd219970081840ec70b20c096e1be5b162d058c5c6db0b672d4e555effed3ec8ca85dd69afaca09d85cc206d179994b6a0443ceb4e65071ac7a64842c8a6b2eb8f89519dac433829865747586af18ed1d8d864b75baed59daf5d08931cf47dd2c802545505c9ae8d351ae00efb35c5725f8d3cfd87c7792b084096af67c7f63bccced4a038d00fd00013449e73edb7c7fbb2df81482f1d22d02eab854f7e7a1362e9a41a95d0a8ab7bb68816dd19776e0cde728b6cca402b4271b384f71053bf501ec8cf40167b76661d11aca1ddf4c47421be72577dff8be5ca3461dc8cfffcd99fbd7accce7ebfe12ac6c4f8d265b1416464f2b94cb93b40957eab9e01bfaa4e33948fb2de3cb093db74e914c3f048eca8699735e3693752fd59dcf48cc92b63594b8595052ca405ac9191c6ad3cf08c6d92c384a8eb0b643b57e8b9f91ad94ba1c7f5d8aeff0baf1a905ae1d722af5d1d4da2e56694a7857f99c114eb63d6914b0ad466b6ff0970457730cf6ebc607b1064ab3e792833de818ce7f47fd212d98dedc8602d90a08b281fc8f5ebc335769ddebdcc8fdb0507d0d814fe95333b151b6518abc1221bb431aa83abaf371451e4ade47142b1c1159b372fc95380aa1697935bda8ac28ef6bab6c69d6871ed0242087f1e69f4ebc71066bac94040f79e5fba35c0bc9085546634d5b1fd7f5c85577fd7b645845ec87623eefaca134432ab7663dc7f5a66f55a80081cdcb09b132c40a0e33f8f9c47fa993a3d3c78f5b4d8b7c0ccd2e343cfd78819fb9d6556e3ad0acf67c85cf5cdd9335665761a091200ce34bd81172e0bc87efdac66ed1d4d849f9b1e94ed2db44601b8f07d85a173a6ed9a76ead0a21d48421a608e17baea8e9a6b319c0c41fc5917263f6c93208f9fad8ae2b2eea2f702a2fb60080f98b3379369444ffa8fc207955e7b01575c7007ed19ec7291f28b93db7aa7148bffb9f98f7e3ec1e1f21568ad37f91c4603d3f276ff0fa7e9ee8ade05c277775c3ca2440d50d427a23f7aba81b8b1b9bf3ea8fcddcc3c3a7708601688fda8a6f41d0cf7b5aa4a03422f332012ede8fa6a9d8093980f07b87092bc6e48d5ab343fd0001840e370e28d6cfc75778bf5612efae6c33b4dea0c4964e810fec77ef1875956edcd2e22139a485fc4515fe44c57149905efd72b16f4b367735c1e91727632507aeece411a472e4f270e0bde542aeab9961d4fd0898b821a6f193dd42391de664331e33b9244e0598669269c73125b21765f048e0c2b9d17aeb0cb3c112a318040ea4126c43e0f46d1d9c1304d95f35b875cc2fc3964970e3602cc51f2c496f108f904e2dcc8113223a9a074c344b42662c3fa22490db6ac63a1b9abf0dbc97feb0f4447eed2e96f33f854f695ce54e24b9ec180cddc752cbe66fd361d874873ea3733a4ad92870b24efbd22e928deecd7d4293b680843d75127c0eec3f07f894fd0001266e0fc8977335a776a66b44c25dde8ac3f40f2d195dd3845a0019b5062043d1e1f24adaaa3052565db20717cdbd769bc1cbad88c0fc6a205fa4acf472f954d161c3450cfa0c622b3dbdbb2813af60d47870299c1f3d793302c678a2d4cc7705ee51b675dea5955fd7ddf184cad643fea3f1a428b6d49da35ae251744cca95446811aa79d4edbb1399734c3b3d71c7ef455eb73f72ff013938ff65ffe3d8cc8ec321328775db8884cdbf9645cedfb4d86bd54cda89c7a4da2da5dda4d4f459518e058d3614a4a9475426c64a16bb206d2a02decb9e301ede48dd0247d36d5b9fa1e449f44f6a3ea7999f31259bc49ea13b62cddc0f877435901da6f9cde2e4ce816565e8a37e36ddc7eb0d1363f2d0641db79c0da0fc7346cbaada28e859cc7bddea3653151df18260a7609aef925c9de21299857732ca58631eafe768ec58752d63a48b65895e428bb4054b8166e61beb6e8127488941601c0ee1092f695ca0fa9d7b965eaaa4dfdbb9fb2127a75447d02f64bd3c4f4e285e781b02d98b21089000fd000118a3777a8b2c2a8e5e9f7fd85ac71b6e843ae5ac31d3e192b73c830a33eaacd7ed6f9d5aed4754b9b6af55e60dd31ced5d74d2910c2e9a500dd3fd8e282136337919b3e8faf81a96315f04588f7a86786b922c6ada489eb90bbb8b1dcc85e8f6c6d3d5fc561f4ee579e9143fa4726cbf168119fa5e2b1539327327f00ab363eb065aed240f5d120096951933dd3e689118d2262e01e5827c120f53f80dc8c7207f2b633aaa0ebd350e65882d7e9069190163975682eaeb8570c6c297b614a72ab6a6c3276f754a7fec8ef86c50ebec46bf88701ce0c3037db989247de5ee7aa731ca30af5bfe9357e3f0de64160b9ccdbb0eee9885478a6e9aab6902227933c8800ae488d64c37f7e8713e92a1ef4da540c5da96b55f67bcc7e5b3a0950e75c0e75edea568a951e213d8713c034245f6d6e307cde14b2d7160bb2ee6628d3ab486d2a0a9c760478b56003f84f6ec15c6ecc44ceabd1d1007cbb891c005f62fce138af30cc236c0e31e0d93be2f94b9f3a7ecbff058bce5fedd4bd294ef8bbaa0588002c6177026c1525bf82d44c5458f84a9105b3f6eda233d83608b024ab3aa3646a9baa480c983df90f90be078d68a80292b8a609180fa075f93e99590018c49e8eda3098caef604bbb643ea3e4a0ce82407a80a13e5f5c786746571e74883e7548d881a9ad516d0b7ae5018ef44a5a93e227057169302cab4666a35b3b22ac678fd0001df3435a26f04bd17de1cf54277d5e8b52d4bd552f2b27115e6c65a252007b889c3d5178dd15259d2216e0e3fdbe065b38b5f638fbd77ade95f13882aeddcf59d508f0252647a0d705ecada91727ca37541f25aa4061a9ca2e3e3dd2169ac00a508db5661b41cd1b626a5d64e9a093b3509d86c122264b53c5fc95d013c6b8d58a9faf515af46d5d41610ab99555c2c3ab363d604fa147b0f1bc86a3da26ad8a4614e6a27f84f58b02b698c232d6a6d864e49d1fc95aac2fca78e1483c53c6344a731ea31261a19bd5b7190d9ccb8ed161d963d4949d17bdb8edf19d1bbd0fa9311b2d5f2b3febc5e4dd6e3c6f2e169ac81a671aaad0723ff8e6b0228ce57ef8e81423a9b6290dbbabe3ab5e8cba4c4e6453766806e946f6261557c2b23c05e7aace181919a12ac324fb26f709ee0eca8b746eeead2b12c13f01c39d5b117bdcdd39fe102d66bccda50456e8994ef9924e22ce634ae801c9cf7ae24d72fd568379bb6650f77af9dbaeacdee02719fffc07ad660fc01b84dd88f35e6f97ee3c977b40080a08b918b6a42c1e2cbf0231135b5a666a371bce41d2b53ccb47dac374dd8a1b9d0469281570672916c4841692a836200f9d4dc3d69b71fbf6a51ab597c6b11ee6d772fdcf02350817e4d85f79437b5aa21ed0407fe9689128f9166bc391ed499357d91b262930834e96b1fa8505ebd15eaf85ff38db7ff5e9897c1ee9f5f883afd000161acbe21f4e6258c082bcca1879e68a20989c95cd5f7fcd1817b807d6819263f1d32cd7882282db7bc2a94b5ad3ff053198fb7d89b51c0df65493652932b4af07023ac9a84793269798b2850d1296aaacde7fe3eac56b88ea9f6656e4576b58ab9eb13dbffa731c6c4a57c2d06fdae1ca33e1fd07851afcc9d5c6021a1e0b3524c72bac8c9ebce52290043b3596e9bd0639220d164fb41017e08c632fa32c798c61c643af591b15148d585dba6baf61948e53d74a42afe3e45505fa249c6b6429cb40108f107983b50c8acf42c9822527413d866f3605812c3665263b0796e7a0c632464c131963eb1b39eb54b6d117fd25fede83fcfdd5bfa3edcee638196ae80213f7ede12505476e213d56a89224818d5764679824cfb61f0365494e5a4f26901aec701421c83145e75008a4472d66f06ebb3af51b886a2325ec482969d59a86dfadd0073596b1d47545699252a94475324ac07619c4b2664ee6f3b83dfe1c7ef6ece6a73f1040016d887adbe9d8e076de14815f8ad6bbf89d82b11c8de622d8094d122e7cc525f099280b1d3bf5557fae729b8b4287fa3f8a92623ac0cd62ab57a10f3fcab1493385a909c09e80ae7f7f0d8bc7459ee95930455412ddada18aa6bf8f8d98e5b7402605066cb4b6b5ff5cab305771cec6fd000032a289a8722d0a402afc66696798c6106be690d05ada3add2ce5947851c79dbfd323dfeb194718127e1ab87badfedea9cf54d4cccdbabf719b5d4af7f3697343b59974b5e263687aa60953df4a207784484d8867fef51eee561964b4b085ba35e822c22bf33e7fe10480f5a63666a5c654c350390521cc06b63b9b215ab1626ab9bbb8fccfd1076140cc362ef54ac515a2c924781db8d102e9b8b64149ebdd98bb102cbe31305ba0081c572ecc50846a7dd2e812f5965e3b12f571dc933b0bbc7492e8d4a593191e01ff9895d3807afded49d76ebc51e0b2b0072cf3baadea00568925088a1a6b1d5c659fda5a6a9af46329b067f5ef1f80b2b5ac6bce86c909f96801fae3f620b9435f49621a88f47da90ca9d7c8bb3d52a01897b92f78f60c64c9b7d9b2e7a1503a000fd00012328fc8b7f3b07c470ed2a03147afb9dc212ab82e70241794f999e711ecfdc51da0413b06167d6873a91a8335a5b2df0cf067681355bce28f19f5fe2a5c74dbc334d077ceca07c981b94c89f5b6aaa03b70fa4a2691be52d76461356320efb66d1d30a771132b6dba09c35de3ac8d46e9894a44a5e3757f17d2c218962af03aeec834380e2137479343535fd8fce9f21cdc55b3ba54a8830892a1afe865c39804811be558a668764eb6c206306132bbec65d21de4da92d9da947e28bf1fd72dbb246f3d5a32bdb7c2c4c6554c80513858b8b863f66f90df7b84299ab692b4f638beced0e40179b25ca27cadae375e6494298f1af1a9c6c8ca4c27012bd5a578dfd000100a3219c72f3226a3dddbd68ee35facb6b68f03dd8224e56e3f09fb6c1e8a5828471890b83eacb0acb587e1dcada3daa1bcd82e86588a3bc4258cad212f965ab7b7b67f1a201c3fb65fea00edb539524c439bd574a52795c55307dd5c69303e9acbdd82d227a866708e391d39f30a450876879e9e96e1166af40843d022a98b6fed4a3a75768ba478c8c51937b0feb9e714901bfb03625225c8f6079fefef5116d58da16284e292538496390666b292add9794937abb940b69efc3689de9a1d881128212f3da736d62a629ee1b5f0d0b9f14f68619352a190694a31d7cbead5c6e88e92882d58ea2b4b2d7fbd9a21abc42fa72b751c30f4d1dcd2b1d538a9ac580ad45380cf5586d1862e7b5cc1f5406e07ed7c9f8e2d60b51fe478c8cb1d0419eb74699935bda7fd39e1bdf588eada0a34c61b50952c015c3348b140b862124143c5a6bff8a95d55e96af5282d0d6e75de6b4d018cbe8c314e0e6ddc58b3a9769629e7b668119695c7454a83e476dcde3e823545911058b4a3c63e34d1045296dfd0001a2656e2e929437093f84eb1f5bb7128ec028b07421266121318b060f0de4cc8a979142cb6d18ea76af9e768249b046b79e6c134300c686c706266386af960f5139cf50208344458dedf8427753aa31c102e5a9c27fded58c1be68b6eeb1e7486bf7ae4089f189ab4b2f4cea1390749082ad37909c56bbd385a3d0ca67777c50c31b60a9055f4655ea679c3fe517e9c230a83beb74707d7d3237343972259908c0844814e73c838a8f476238a764c89fdb7dcb41ea693d81e4dd679f51cc7563e0b317336eadf517a3835e2d23ced4b898a4263a37ba4c40655240d10cdfbd608e59a960b3bad9f86945c4123ae65fee8cd0b11b8b3e4ce9186fe40a0d184a7b2814d798b5e305448d9efd326751ac2b5135e377165ba43f41dce4d20a469df4d161006f30ebdf5964229057ea556cc9c94da42385223d8d4a9a68c98452eb4eed7efc9ac9aec7e115e6f0ee4dd8a59f0c3af9025c27263a0493704548204fcc9d9100493fab6bef47752ed0c197c7efec06868d5be4c6a7085e44142d0681f9cc600fd00015fbdb2b09918819b56dda812b5213c0b2ad01cf668900c07402e80b28de1286dc3d073e305f0ab61ce03c3b5dc6661e8607985e0db2494ef615d15200cd441a409d6b579352cf40d8afcb386f2d2e6b193a1d4107ec1dc395c5fa542518356a5d2e60281396b45373a88655a899f0440964b6897e59bc6e4975ca80cfab2329bb4ab9e64d55146acbae756fa01de037ba67f1b32c1e3bbe3c897d442d91f2fcec723eb41ad81ce587f89362480642981290750dacf2d89ca70b317520c13d535438e7b3baab43f0f9470750b3bbae829dda95da000ecffe01a4c78d0c62c15218a88a31afc3ef3f8af914c5975cd9d279491c849265822ee1acc28384e5fe0bf8037b0d912da535403f5b695565c69b8b0354f5ef2a7984ede9b647416cdac8e9693dbb793a8dd01624515b5839d8c9df43185e5a534b02cbf5211a5a76901a5f290d235514f7ede4c384033c438aeb13e916b0b3808af47a069d98c7558bb411464d4f6f47b9a27b0c108ee0105accc00990c74dca25dc8670406c9515b6f381d819ee591e0d496a46333f86fee821a2a99c57f033ddf7f5a5ae2ccad12fd802e2466086269dcd30d5bf166d0e75e78ffdb17314b500eecced0eb89fca7b80e7b072c1995b83aed72d5b033c20b31eff559d64541d17a97875da9075fbc3f2ba535b2cef3b7162a395d150634cab96a316c7c53d4ea11314a33afe53001e41ee3c50081cbf279e81c999aeb762f1ac725354d6e4b1fb7c81630dbeff0b04745dc66616ae25a2454e0f0360bb18079c1c76ad458ae27b904e5b878056d0e66ff205eacbecf2abc3742639611e2b638500423dd50fbc25f9972c1e4e9c2d0e84cd048e6c7cb22b11b3a2ff56cea64b7bd061ecc395a4d56c24be981db2975c234be0b2f81008005b631efbb2fa8782f143b7de38c0de7fd23c704c474c76ae0d36e8995db201572252634de4493f1e88f9abe2635e004a84d27294256a6515b003869890d730d1b9201d75b3fcbdbe0732d4c14543d1be2ecfc827546b7be930d1028972715e99693f7daa4a8a240248d07912d9fdeb64ab0683ea51c2f4d3dcc6787bae732528012df83c70e6e3600f7e891987318ad29b9289ea6b2e2bd82ace318eaa84c2fd8a490b6d34bbe6a75655a09a29835a26a53cb859c9930793f1ec0e3b178c58bb860145c5291f5cbed4c3eb998ac512c603964527e1e5a0f5356ed48b3d0a73adec895df93a5ef9284231cfa621ff60b936bebf4aef2a744a0fb7d47ee5f43085e802f80d547703739c5c00e8dc4a72cf3995aab570f821f775544c3901f2dce970d1b4129e4c28b57e8ff266329e0a37b6931fde5e5c54922291fd305a948afbb18f9effd5d0f60a60d35979ea239b80e249ef70b39f36ee312a4167e0aec0aaf23bdfcd7c53d64249c6351a87f066f8f9845901b7f757a05e0a463133e4df6571981a2c26bd1cbcde2cd0690917112f976ecd8be98df9d99fe49f98a11b45f5eabacda1ab4451ef9cd3d1abde3ab3961a3ad111786470e3ae064b5d3b5a16576834cd64ecab08e6749aa502ff3b7057b7ff751c234ec9b08ea5e4e0b55604114ce837b6718e2ebdd73d1b4cdd2307ed0f1a6123f4e3433d3c3fac7f08a89975ee59d00fd00019612c87cccbc8ece8380fb088fe8852362b7702d24848e60213f33efe5ef71b6d76dd868998e2533cb85964221e103dad6049dcaea215a58610ea64eb88bcf4570a5cec9c88ceaf735f4d77d676dd6cd2abdc1c2a9d5e8d625927294a464fabc08ed5a769e640320af871b6c10c0b36ca09c398de5de8932120719d2e71c481536250d2eac410ab88c4970c68b996f15e4fcbd4090ff80f8677d55a8eb1274436ead2f15cd0f0a141d0706fcb3eab92392ccaaa36bd7f4eb816062bf3539a4b5483191667b1db7d13b5d043ff2c4a105cbf74a5f8450e16ef9e56c521865117cee88c49480177b5507d2916af69654cf760c5c5ec886878c27c2d3c8188072c3fd0001dacf4c63a866d5c2bd21ecd5441afaa3767666957dede948dec007df174f22cd53c05bc5cd2a36ca827fcb8446cbbed912759302cd005ee6a2c69454a3477db36bfac8c9cb9c1bfa913cf4c43f72ee57ad9a4c9c8f5551af8385d855226e2f18a55291dfcc1ae5d60b8a79d840cc539b926be1983c2f16067b104e2c63d8253646c89d31ab4c50f166105a04c19a10ff75cb467ee83717ed391c72464a7fb3772eecc36434bf946de0d03a4d4a5de6c4bcf8ef8643322167d73b424828c43a14dd635cd9db1313e4d4ce5254746fe90672606a0ae15ee5dd388732ccc0d3a4aa489480e6ddf7e6e0556d469166a96181cc439500ee7823f496be42c2950808a4810f484d78b6727e793fb2bd80a89e39bd21df6fb17584aeacb871ddc341cc6b7d10e0a476619f4a1380db2776c4a166acb9109a9a4e3e0d1c722b1cfefc135947ad866938137a8d8af919f4010e380f0d4bfd086059207e5a6155a66325355282a7453cc87c698fa58dc8c3575cd81649ba8ec17ac529ca74957e55b9f4bb56bf00815b9bd44f0df8280a75a1c07da606fbaaa180fd975cf0fb680960c3c3e6574f5db9ee72aca6974ab6cd40306bfbaef658a3613045af4e85edb81331919d3b9fbaf742fd7873c20c7ea1460abf68aeb1af6f3cd8efa6e8c072220c3c5aa34f650d1d946e400bc038be346c92d63a2160048aae8acfd9d14b707333ebe2264080b700fd0001d720e0212231342c3102b8025ed9be48f49d2e97999df5268fe485db4b86020262c62548456cea96a3aaae658ec4fd624269d96faa62933bf6da9f251553dc3085f61ef29fa80e09d651423fdb6feb6ec0fbd5a0e5cb370aec10f239d2858111df0053151f4697104af362d12224e069e0efe3d23f3580fa7dbdbbe2021882b7236e1cb6e589c30427f5afcb414b96b5223fef25d08efe1cc1b94727d05abd7b5fd828d8be4658e0f4467314f3623fbce8484fb58258fffbb0dcd775c78eca1ba6d0a125412db9915de16e6a7bfc80ce05108d8a1a29ffad260498f3d7d505a729b6d80f78cd7c84176c02fd37188433a8481b1ccd90239fd7e7041e2e9bacc880eb7e639dfa8733cb68a4cfabfbd894e153b67e939cdd425a17ef4cdb3c77b204dce1479bc7205b856461de37523dfbf9ea529fec53f78e737ae178bdfc1ccf9eebb12eb9fe4dba614698043db45384cc80ea339e16b8ddca87fb472f2a9ccb2b5152cf06306e3fa48824e5a3fd37e9552feeb586f426737baa0cd20b922235638096033eedc83083b149b489f7cc907ebbaf77e0a7145e5aabe02dc425aee3602ee5a8e33a0af6f84cf8acdc541c7a1626974b21a88088e9f42fa879852ecbdae5df0c7be841051f73c86d5e9af4296da7b84756afe65101ad68ce8150a4f44894c9c3883c7db6b9a8cd7080842e712d37180485ab416d4e4a229270337f4c68491f5457ec9b3b6d4ffdd935903f8e4c036db95e032911714af527430baf622582e1fd97f6581822672800bba9ea246ea960aa19bb71102cea154dc463f4a020561e2a40e835f9506e2c1bbd37b14bf169fee9e41d94d6481153999c6486b28361ac1db2896543a42d0e8d1feb8c9f038e3226b19daf72d454dfd6d9928beccc3b3132d1f4e996ca49f276000e9f48d9ef6726c487c0fb0ede75c69464278892803a74fa9ae56044cea55931ab6c214fe440522aa3770963ae910e8656eb8abe6d1ba0f0633644071a1cb7dc1111eeceaa16a2c3ddb66a555db4c412337dcb28895cee7cfc1dc8304a16fedf2f7de4fd1160d528087c2f55ea33a2c7feca95ca24dc71e1a6f0d978bf0d36bded077f5d55490ee150f2f83ed8009cbe76ecf7f922b047052e83da4706ffc1e2a4f19b3ce0f1c1ca1279bb60d8b94069cfb3fb5c328d63bd821e47866b6902a3c85a02cfe1be4664418a32eed422709c5a536648805b726b053da8269fa65e5945c3f0897566fff4a9aabb4df74fc48378fcaf9cc69d7cd820d0dd682d41af39663e3a12ceb63a4f5a875d2a1df3aa924270ae2d4640e53f8edaadb3f53a8a460dd5c7d3c5cb54cafba1911314d809091d593083dcf397a2e4b9258ced80169c0d7728b869be03b5882045a1afbb0275b1073bef509f2db72fe133df00fbb27ee2ef6ce6fd0e2608387175de108b1fb40b74746a337531375bee5563b9b2ee6b9d803be7bf52b7013f87bf4b7481641b2ab5479fe5bab4409858506ebd120c8350a53ac86e15c8c12485ab9f58c218c9e2f44a39633abcc333017635b19af3330dd4dc275e9848912363a85e7cb3e91ec588b171e936af4a63768edffd74fa05a2fa9e281cff6eb2d801b2fbb8e208dbabdffd387331b9239f53a80414cc223a6230ad96143e624f210d541de65584ebee32586c31be681dd2527d2a52576744ebeb91735f4ec6cb2f4bc8f94dc0f810fbab5e14304c370ea8fa4c208376712cfedf6dff2c4cea55968d3316d87cc68f0ea97eb59e79a5822b9e6e69020000000100f2052a010000001976a914b9e262e30df03e88ccea312652bc83ca7290c8fc88ac00000000 +0a2096ae951083651f141d1fb2719c76d47e5a3ad421b81905f679c0edb60f2de0ff12e20101000000015d29bd6aaefc76d42e3f23340324be0d235a35ff6ab80187be75f3c3d9cf8c44010000006b483045022100bdc6b51c114617e29e28390dc9b3ad95b833ca3d1f0429ba667c58a667f9124702204ca2ed362dd9ef723ddbdcf4185b47c28b127a36f46bc4717662be863309b3e601210387e7ff08b953e3736955408fc6ebcd8aa84a04cc4b45758ea29cc2cfe1820535feffffff02002465c7090000001976a91429bef7962c5c65a2f0f4f7d9ec791866c54f851688ac001194fb180000001976a914e2cee7b71c3a4637dbdfe613f19f4b4f2d070d7f88acf8ec010018f5fedae10520f8d90728fad9073297011220448ccfd9c3f375be8701b86aff355a230dbe240334233f2ed476fcae6abd295d1801226b483045022100bdc6b51c114617e29e28390dc9b3ad95b833ca3d1f0429ba667c58a667f9124702204ca2ed362dd9ef723ddbdcf4185b47c28b127a36f46bc4717662be863309b3e601210387e7ff08b953e3736955408fc6ebcd8aa84a04cc4b45758ea29cc2cfe182053528feffffff0f3a460a0509c76524001a1976a91429bef7962c5c65a2f0f4f7d9ec791866c54f851688ac222261345843445137416e5248396f705a3468364c63473367376f635356325362426d533a480a0518fb94110010011a1976a914e2cee7b71c3a4637dbdfe613f19f4b4f2d070d7f88ac2222614d50694b484233453141475069386b4b4c6b6e78366a314c344a6e4b43476b4c77 +0a20914ccbdb72f593e5def15978cf5891e1384a1b85e89374fc1c440c074c6dd28612b90201000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1803a1860104dba36e5b082a00077c00000000052f6d70682f000000000740a9e7a6000000001976a91436e086acf6561a68ba64196e7b92b606d0b8516688ac002f6859000000001976a914381a5dd1a279e8e63e67cde39ecfa61a99dd2ba288ac00e1f505000000001976a9147d9ed014fc4e603fca7c2e3f9097fb7d0fb487fc88ac00e1f505000000001976a914bc7e5a5234db3ab82d74c396ad2b2af419b7517488ac00e1f505000000001976a914ff71b0c9c2a90c6164a50a2fb523eb54a8a6b55088ac00a3e111000000001976a9140654dd9b856f2ece1d56cb4ee5043cd9398d962c88ac00e1f505000000001976a9140b4bfb256ef4bfa360e3b9e66e53a0bd84d196bc88ac0000000018dbc7badb0528a18d0632320a303033613138363031303464626133366535623038326130303037376330303030303030303035326636643730363832663a450a04a6e7a9401a1976a91436e086acf6561a68ba64196e7b92b606d0b8516688ac2222613569644363484e385759787646436542585358764d50725a4875426b5a6d71454a3a470a0459682f0010011a1976a914381a5dd1a279e8e63e67cde39ecfa61a99dd2ba288ac2222613571374164346f6b534646566835616479717835445432315254784a796b70554d3a470a0405f5e10010021a1976a9147d9ed014fc4e603fca7c2e3f9097fb7d0fb487fc88ac22226143416754506774596341344579735534554b4338364551643563547448744363723a470a0405f5e10010031a1976a914bc7e5a5234db3ab82d74c396ad2b2af419b7517488ac222261487538393769767a6d6546754c4e4236393536583667794765564e4855425267443a470a0405f5e10010041a1976a914ff71b0c9c2a90c6164a50a2fb523eb54a8a6b55088ac22226151313846425646746e756575635a4b655667347372686d7a6270416562314b6f4e3a470a0411e1a30010051a1976a9140654dd9b856f2ece1d56cb4ee5043cd9398d962c88ac2222613148775464436d5156334e73705032517143477065686f467069384e59345a67333a470a0405f5e10010061a1976a9140b4bfb256ef4bfa360e3b9e66e53a0bd84d196bc88ac222261316b43434764646635704d585369704c564439684247324d4747564e614a313555 +0a208d1f32f35c32d2c127a7400dc1ec52049fbf0b8bcdf284cfaa3da59b6169a22d12d60203000600000000000000fd4901010094140000010001eb44148fbbe7c36e1406ea68701f9b9dd5e10f3aeecbbad9885d73846bf0891932000000000000003200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000018adf080f705289429 +0a20e5767d3606230a65f150837a6f28b4f0e4c2702a683045df3883d57702739c6112d70203000500010000000000000000000000000000000000000000000000000000000000000000ffffffff0502b4140101ffffffff07004e725300000000232103fb09a216761d5e7f248294970c2370f7f84ce1ad564b8e7096b1e19116af1d52ac80f0fa02000000001976a914296134d2415bf1f2b518b3f673816d7e603b160088ac80f0fa02000000001976a914e1e1dc06a889c1b6d3eb00eef7a96f6a7cfb884888ac80f0fa02000000001976a914ab03ecfddee6330497be894d16c29ae341c123aa88ac80d1f008000000001976a9144281a58a1d5b2d3285e00cb45a8492debbdad4c588ac80f0fa02000000001976a9141fd264c0bb53bd9fef18e2248ddf1383d6e811ae88ac8017b42c000000001976a91471a3892d164ffa3829078bf9ad5f114a3908ce5588ac00000000260100b414000037ff812f8ad1814d4acff9c45457873553fa2ef12602c1386ec6894e7fbb9b951881b981f70528b42932120a0a3032623431343031303128ffffffff0f3a4f0a0453724e001a232103fb09a216761d5e7f248294970c2370f7f84ce1ad564b8e7096b1e19116af1d52ac222254416e3947686b7033316d79585267656a436a3131775756485431344c736a3334393a470a0402faf08010011a1976a914296134d2415bf1f2b518b3f673816d7e603b160088ac222254446b313977504b59713931693138716d593655394665546454787750655376656f3a470a0402faf08010021a1976a914e1e1dc06a889c1b6d3eb00eef7a96f6a7cfb884888ac222254575a5a6344476b4e697854414d745242717a5a6b6b4d4862713147367655546b353a470a0402faf08010031a1976a914ab03ecfddee6330497be894d16c29ae341c123aa88ac222254525a5446644e434b434b624c4d515638635a446b514e3956777575713467447a543a470a0408f0d18010041a1976a9144281a58a1d5b2d3285e00cb45a8492debbdad4c588ac222254473272756a353945356231753947334637485156733670436356444278725176653a470a0402faf08010051a1976a9141fd264c0bb53bd9fef18e2248ddf1383d6e811ae88ac2222544373547a515a4b566e3466616f386a446d42397a51426b3959514e455a335866533a470a042cb4178010061a1976a91471a3892d164ffa3829078bf9ad5f114a3908ce5588ac2222544c4c354751554c58347542667a3779584c3656635a79767a644b5676315247786d \ No newline at end of file diff --git a/bchain/coins/firo/testdata/rawblock.hex b/bchain/coins/firo/testdata/rawblock.hex new file mode 100644 index 0000000000..7c10a078d0 --- /dev/null +++ b/bchain/coins/firo/testdata/rawblock.hex @@ -0,0 +1,3 @@ +00100020d9e087132639b8836dbdc8f1dc2eefb4ce121fc70fd465ba1ac3bd43a919b4a3fa8d1337d4af7f9197dfdc1592b286a8cb68314cf3685f344899178b8b949856ee2f375c188e0e1bf81cd1360000100057fb2a3c8a4b188ace7021506ca9724cbefdd79c2f27ab3a1fbc08000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e14f682c06e160d419d8f779425c4c63bf38ec654abeb231ebf7271600acf01c41bb5f6e40d7f53bd236cbb48bf8f7719a6d5078c3f2692e4ee3fbe68873d7756f577d7761b2ca72531723eb241cb339d40c8ab9dade488a179c935a07267edafe61a5d44064324b9462ba222e8dfbc0c2ad88bbb5afc668918e69034b879546752dae685f0854f945270d1e8455c881fed1a74403ab3052ca79df380069e8ef6afb4267b7cbf5a4bff55490b8f8eb35f64c2536b2a532e49859d317de1d33b18f872aa337f95596fec720fbdaca6198e513c1215e97193b43b99603ea847ee866b0fc5d48e526578f10461867282f056b0d89b62b9bcd3ea04bfc88bad0d3677e38961caaaf6ae70785633de945ad4346e40dd95976401e9913bc535d491ec223b444b7deb5af4ff76b2de0cf26795f96c4e60323f08eda7abfc1b5dec69517de7e4a6916841f3e797f67ed13e9187d729db5958ecb021fb8699188569fa80c665c3c57fa56b81848b9528e9956be7399411cfc622832db2680c4fc27e6c472bfa4fae2c39d05bbb992090e537bcecabcf26533c1a4860418f8fbf8fbf07767076739101d1b192cd6a52aa1c66a06093d7220d08d5923a8623e22c44e01a8457b5231286623f5e1f380281bef17d6974bce04baed55002131d2420a26922925767bfbd89a24595c2ae17a9bc1550ba4518cffb8efd1df202b04cc29ab46a2779d2a8495234c875e7b5fc0edd9606e1a95170d09c0c18077a701c7d1432de48a34c7f3d9c0ee8aa930c5c4f10464f2133ed28589daf56851f34df5df5cd08ae7306bcde570bded83ca1daf838ad796824678d909d192523ec6d5b958e26c004ed3b1b35bbb619f9811955838175aad6f8bdf905a885a768461521c7c7afb84e3337cd709f4bc322f1fabf47eee4cf010ccad37f15cfebb062c1a1f3b1c03ba2024c76d4540c3e5ccb411f6cff9c109e6ee3ad7b54f32a5a017f2cc70cf001d48e2b760b8779a7004cd2a47db10e5f7c486265e7c1c007d52abacfb7f2cd41a2a28441fc572313c8b7a46be5073f037b23e070d5e77646371ca5ff5e16a0ddc386f1404b85f918fa3fe865233dbfca52e11b1d4471069be26a639094bea6eceea157bccc54a2afa4e48ca658cacde8873071b2144f5477eb6672d7a5d747507c50d16f6fab9d737d35955d2913e274bdca2f00a8a5fea1d8c117099810dc2d4ee204770d3606f6a3a70fa73852d5b5d78060c1ae100d4b471acd0a18c575a44ba3438382b0242065f614c853423f0e1587fde52aac7f70266d2ab31936a1c5baad733de3b35ef999f0a3051a355d3aefbca3c2e4b7ab07d919ec2b61c4815b0f0c6876aa84947f56354ec220e621b4c24d6d71310ed0a888f2108ee51b412befed3e483b49919d31bfaa360e01b6606eada13a19c5184cd3ab3dfe15136b62abbaaeeeea8c32afdefc98bd983ead06f865a32e144a3451a503140437bbfee0f5c913a744f59123332026f0ed15e9a38cd4665ee4dedb407c39606376816b00e617e241b488ae87d61e4bc1a7a15e960a3616fdaaaa814dd2a9eca5621d5a80d7337b3723e1217c1a6f9a1128a6750af9553b454f6a00659867e48c720e22ab64618bc90f669db7596061521ee46b718b255881c90c6cc1043f3307e096658afac71d23cdcf8dce25d82f9f4588671710a0b3b60287b81a8ed007aa1a69efe72e45872c51868a210c5273a97b9356110c129d4f00324557aebd38bc1bbeaabca03b63238a77b6507c3f448ba904078a7505a36c487e41b9df9267c0455cfe8b63657ad6e9f5e640a86731718e3963fb72b4ad92422483a14a63540bf51ba5680d4663954b49cced13dce30eff647895d18af9478b1cef01789201ef516d2be0294673049b6f382a86218867ddaacd1158ea8cfd16f38fbc07071a46b2a638d45d87cb013993d2c6f43a3c450736b8dab1b09b63c110b26635d9db896fb50461afc2d3c300cd30b1061c59de24fa86a6696dff0650a68d22832f258affb0168b0d0caa3cfe5214d7a5326dc318db381b3bb53be596c19330aa265bd9cfb334c93bd24ac5bfcb045ef800152c322b6420e156a04cd267d23173ac31dc03b2c0adae1dc27fadecfa3e617b5a7886254aa734130236b547206e6919ee2f615d90c3e7b8063eba705eb29b387f4148c693e5b3458cf5963c818cde2da1593191e188601da333009d50dd9721917919612247a21c5161d45592028ce9a0c920fb06c49ab7b394db6e810adf7bb57f38377fee2a10ca40e0a320ec0d0e4f31a2b3abc73acc08611af33a1638bbc0bc3d8436c4d8aba856ff89bbc8c6269ad3d858386c6c759520b923bb634719762f2a159dccc6ed54d2ef53054df03f6caa55ab37b2d64dc056ae043bf4da2a38db7e37ec1f5c3c19137506ffced1bd18cbcfe362a443814a307048d3f885b1f05c238e7857199b5ec740faf8834e3af030803c987496875f9ec9a17f50199af2ecb4529c2d474cb6cea1f6c72a89233279ceaca567c076df3115326fca59f8db98584ec494b85eab4d5028e0eb3f8ba3ecab10129124b5f613895d0b08c06ea6e2def0fb1196ded84d9f2cbe82953e82cb106a6de25ceba32c25115082fb2bb1f761aca7618906c4a47772ef4d4c8519ce6385d68594f87ecb5633105cdfe36cbcdd10bd729c62d65363633f93131f3888c7b37de83062458dba533c9560ef7fde29c129105d84be73d15abea864d46e8d1d46a9db872b37ae8cc8bd0b6ca92d086c49ffa3d959abf4d9b77d5a07458069f46428e92285709ffc92621efe423976f0b171db05e630b5a2441ae2afcd1671a0230fc5caf0ebb4499a9ed389b46720f4d2f854aae3485f2dcfe2da4079b2ee8041593d50cf5532299e55e77fb2e26f0b3ca9f0c9dc5be312f93ac552cd95b312c953cdfa485956d6d2fb055b698ef99b62c5ea338d2c2b8bc6c0b5c377999bad9ad83a9580b0395a9aba7b78c05eb54c687e2b0a5117850232ab5fa7f3b50fd92b72ba3956926a0a962e01ecdb3b077bf300468003b3f636236bbb0c40347bef2511525728cff5b044ae75478705a92e8c899a99b81f368725522441268df41bf46af6dcd991813a7347f7db234f6ccdd2e1ba890457f67db5602dc11937f728c8d03f54e2708514cc824a0a5bbf8795df368fe8fa2a6c0c37532965686601335feab971713d9b5643b3fa1c1943e16e4e804769e24ebe310561205f68fc920b1cf35fe87d9f38a580e20084ca8ada246c3e311ee7937e360237d349d6aee89b4ff4d5160574228806df87f0542c52f95bcbece63f3da9c4515d7380e105361779d006d15b2de0ea9170346bb9e627b6e48535e74b8a691c95d1e46188d1c4415a9a96ebec2b17326cb4daf63a0ecdb38c79ca6da2ea16473a042849d03e2bc674a431d0c7313f0753186e41e5371ec300d667733aeb06fe3d5dd09a24de5ef0dce6d2421645d17c2457b9790abeebae41a37bb56e347e6ddda907bdf6e2b7a85913216e7ab6af91d8997be6f6b3c75427f2dd6dc83b929d7a99df21a0e327046f1cef1192b9b37adff47d6c26d8ffdc29a88d0422548dff40dd3a64b24b14cc8bfbadff39f7ae645979223a1df691c83ddbfcc3a0184757048224dfcdf287407383d47f05f942cefb69dffb3d42b615909de5e35b06332adb5944dbcaee2eb825a6934bd7542ecb44f3e6c0c0e9afc06a222acf604cd829c2d2810900d9806d17d73259deccccccd95ea18b3758a10de9c38443961a9f9c41b542f094db02dcb39e1dbce2fd8c527c2e33c6d6dc7162112149815c839a99115bf5d7fad1860620e0586e4852e1f8eaaf1d400d9761fb444f2bc5d6bce4459e2946b6f435e5e5d2b2b45f4cf1b1fe790a62121bbe11b0799f2473c73b5a5135fdf3991ca60b5322c06139d8378dbaba6f4c3bb312f96fd3b5f60bddfe84221a8963f718af4e8788268fc3668e5344cd40a6d031a85055167cfff49ab668d6d132b364d3c3c6abcc274d475ccc58225b3db25d677e31505f7697f69d56d1840a2a86217db664a65b5a2acb38d4db376e0a6b3b6d39e053e6517d56c55a130c6ee58e94605998d8b4daeea36b6297b97fdcf3c0c94348acd76c2e561a07dce6f5e6eb92dfdf0eb294cacb1ccf7a986eb228d49a68b45a27bbca8c1a95a3684084682142b0874cc354dfa6a5b76b2843fa507307bb27843fe8e945c5dfb7eedf74d7cf1529b18277682240d0bad205ab8660f21cf7231f0f915befd527dd05289f27cb4501b8c6db90f7026a33a3743f38c453c5bb97fd34b2832a1b6dc97e6cd6e731356240e569cf4c084535ea639aff4eef4e7fb4b8de3dc44a063e9bcccd961c98bc1da02ba753979b17bb2c234c0311c502a4f0824b564abd7672dace36b816a49e48a256f5fe29a91c2ff53854542d00a5e1f2b016e87901ad80bdfc0db8c769caedfad9b1616473d040cf5b437c25d8d26aa8572094af14b077fb0a52ccace7872fec8e20906ec1830b7d1d0ad045fff24e82589ff1010f3287ff9ce88da19dd07382620bdd9abd2b2100c7c9892af86eafffcd79f5ba2f69e66a7552c9f0ed5cf89d95e120affba96ee58c56db7e0394f4ea5d0555c1905b1e7fb52a0fcb94157b746f442d23690f6625558a6e566c1f588bc6127b68643ebebe825523570990b0db76be8cae34ea8761f26b8811e2e395431059059e8b840b832e90a3c0f6086bf3ef13fd139999d590678c2b611c2bc3920d6590ab5be15728b9925e644e7e0a424c719e7715b9898f27195d4c36ba12b24303009f82cde4d013ff3bc6ab00c0d627fa3f7cfbf4f569c740517cf4e9a75d9337be569051f3da1ac5c803506c0137fe7c39442133faaf513309ca3b2b61c9146b39e8e25f9d750f3ab95a0a20affac26910bd5f2ad34ac6b7e3e64dc482c1595b2ca10a4c15e6b9d982955cf8dc513210bee7b8265edd2406f710e1ee6e61e42ad436355716b989851bb16904cc92eb3b9106db46098deaeb5cb218fc315eccb8c96d33118643bf4a0a4ed4735dbf90f7dad39a4d7ddae3eef976761a72230ae118693369c3fd90bfdc47fa8586b46c0386cd1856b0e78f9c1247a569fa2b13e6848801a6e7b3a0e6ee05b3368ab8c725a7e62156ea8b541a416f98c8fbfd335ad0a0143976894391b07e304ca6e69f6fbc1a988953bb2d185b34b05e2a235dc3a4c56bb543d145c5afc9619b2ed668e2a66bc22c4638b8e6d267b8fe8c22d8c52618f6848f8a0b274af86c7442007c5d594d462c8bc9a0dadba53feb43450516acebf3beafdde68c6bdbfa2b27dfe923afec604de25da416575153edce82f775dfcd1218936c5692d929c6d2cd42c8e235730d2e6033b94a248388a14b995d897a4677916b9262ed8a13d5bb810c77e629acd214743723b33969c799914a2b7f9a7e2d578c1a2bb355d79934d4f71e11ec7226435ca59227c76db58322a4a083b7e7500458e7cf0a1b43ce329d972fe53487698890d5f1a62ddeaf247d853423da4a1b1973e5a8be723eeef09411c97eae596fa8f9367d10344b71f12407fa1fd60470f034bb8e48a0c8f4207f524cf5eca5ea67ba4e7d7a905062d655ad1f4db3045eb86d7dec7ab688741aff689e931230aa5c1a038ca1a74ff480b6f51d8adb83df105d9bee59eb5a63edab04f4857707b73c944e33c83f6c111c0e84dedb5965b801703ede04fab315b1edf134117f8199775956c1f8030f261a12332bc1276d0b0934fecc523985ff4535937ff0da10ec5a221e94bf89c5ad4e3974827f60a4e1c9a5adaaffed70b3c959ab6dc1ef4d0515d682c06624d3088748d0377039c9eb1c1cc93b59eee4b5c5bc15e1b12ce83faf58d9bb9f537cc9584312ced4ded481a3e8c369445ba02ef137cffa3d9769866cc2e823e449ce76893a0c561bca21b9b57c88133b8f7503c985a104a62573dac8e5ba5fbdb1e9a358c803d0674b33661a29a55d8554ac282104eeff2bb216c323b52cb230503250e59cdc2f99970159644a9e5f40afbbd1ca15854566a6eb30c5348402be29cfa8ad40258c6e54d05b436b5e7f6c7cb2bb2bc4b8d30ff0169572af9f5b5c5aac0ea46834ce6d3607931c60a79622aadf1b22ca993346d7e955aa1dcee27104aeb94e7ae151337f0fad9d7baeefb20829bd0e62827ad96e04832b471df3834d9b202939a363cb30c63e299ccea78edd5126c92933bc88ba89a82cdb240d9022ba03545ea38774e8cf6228871be8b746ed26388a202e1d39e05336fb2384838f94ba1c1405df85a7ae06f5ecb72f0621fa2d9e9d18dda3cc2d734e84ae5c98be889189f54ef5ea4726a0af49a0205913ddcc56ddb53a6b2f37c00cdb4442ef97bce166a1ca5b1778a15af7e9fcc08e381fb1cf7376e8bd6b88b95b9d67036af0cd10e8dd2b35cec032e1ddaff3b7a299efadc33bf68e986267a3c193f3327bc4ec185d1ca4f78263c3a0e309ac207c0e648703362af65237db533f6115ba7b316aa8138516068f1323aaa9cca4d9ba85b78cf96df5e479403bcc8c2c66080ac2269269f8a5b1b13c18beb55f6fd291788a604497ab720063876530e1a72bbc38685886fa66c350b9213e828538f4463ad5d6f01d7162493769ddd30aec40e57f0244a26469b9b98a5b344e4f328b595e57785fc03ea7a6f27841da820d38a4cabfa413954c4c1ede775c5b3dba27e6dea5af9324ea6c542a7624dbd240ecd8fb6e4174402d959747c15f2374d49b4c780d0a06b5cf0e2f59b8c390007fe96ac72670873884cba8eee94c0609a610c7baf245f547edfb31b582a5f033eed2869421bf956b7853a03219186aa02e436b17ee59f24b3d6250dbbf17b835f643840d9eae84a8e42c2fbb2d81e3d6367afb1e2d4139d687e1416428ed75c48a47566bb4dc4d46c13481de4b1d35942d37e775f07586bab14c062abfde126c18193b72919f776d544e60a616e90af7583ec3f6c16a91c561f7fc4634ff021284408862eb93a35c9b90de82e2125ede4c08cd855df1e83328b7fa678d440a236229eeda6a560ca5a48ef745ae384e5a54f8a8d66e5716f4189c5b647800287898556003c5d05244b80017912c5db36cca8b347c32c1ab406129ddcd4926cde6eda411479cd2437d1558e9ec044bae5d688f21be5d13a9c41953252a0c862f16c44f079ea413e1ee4dc531c873795f1de81f992e1d0b540ca8345a730f2abbca6eb048387fabccd94c237632f3ae326c953b5b3ff036f882306fcee1f3fa99fe64d5391483b772d2e3e29aac31ed071c1365e744fb19d8c6adb3b6a81f8e80dc0c1e7e54f56cbfc9dd0e1aece08d5fd4df5a61e0d05c44d751b318ac2ea7b223507292fce04fdf2a4ecf242ffd8e6156e21fb2661c4264ac16036ea7355bd5166eff8fdc61bf9d10157780b43bb775a5829c0f0ce74792c94db3fd051c145e5bf65750c76835864cda28782ff77923958daa140761b9743d85cc356a6d09f69c647d57c95ea97c8e80f91113f4d5ae1bee46f3bd932cff1b166af6ad933cd9a15c6fd20e45bb0f32b1668ab883f351b2afee17f9133a43f0c7215593bca6acfefacb68e069b2cd96164c1933fa15bb6676d5f719aeeb81123e82791948c029b7d782d9073df0c4613f2c9fc40884c51f1e2bbaf76c5c46f47d579447b4901533060e8542f432aace6878358fe404931b9503def8c5fc7bf085bb8c5af22aef694a04ea3ae49496ab49acbc6a9189ff9c9ca09295a30748321d2fa5bec3cfb531f21360b86198c99c0a134549a1f9d9b5137163d69581905bceffc3b73cc93b3173e56859fa44ff23662d1d43397903fa4836ea4d3d7609876bf2eccb74a083f44b0f2c941873c3312dbb7340f514a170438d89d8e4bb8681d2eece85c87b92c51deaab69deef9b2bf7731b9548b8da071b69d2412825fe9d0c08f22975341a0e654de3479c7309fcd01524c886fc35235806cde8140d058e06847572d8ee8743713ada4960c455f7171e78f2b6552117f631d3e922e09c6940e8658522179546bd3902a3d9802d5be24dbc1ac91abfb87287548d9ee64e21e52c871ccbc48c49d54f707db3dd4dd7bd978073fdf2adabf5d37a928dbcf5c6ac52d96b72980531cb7750c3131d59942ff3d8557c418858a884b4fa5d6520f0ddf811aa18b792a46f156b117764cee8e672e0602373882a262cada6cd85bd8f0cc2cd999d204c78a3b81104040c6dde29d318b348c159d2e2e30465161ddcf0a552c21ea765d44bea8af3151c8734d5f654cd3507b75d9e1d591ec30951a9c927e850fdcc74d8945be538821a584f8fcf057194542d60baa3c621f530fe24208e5942d475600322f1e8470adc5920cd94099bdc0db0dfad536843b1063dbeba51d6c7e398e333efaa1b5064136d013b1bdbc86e302d6c788e5e26cdd470d6ecea98b89771c4416ae47927fe9156af5ba408c148625aafb5f9d63c166993b9e04dc8bdce8448804ebbffadf90b8d259313ec3f2429ab98823e9ab311c1158c6a90ceab2f6eb81adcaeb93338330bf8ce78362ae3e288048230789aa8ed23ed05c45bf990985824b6bef40d1db1d3c5f26558dd1f215b880758558190061eb0f1a92fab35838d5b5c9418f71a64e9a68449e8890baf27641f07d61daae902ff785d0e472895e5101e87165eddc7f11bdf3f0584b7ba9a1267e77dcbda6ebad35fa5875c458584a6198cabcab3ce0958707977a1b0a419229c03a49708efe072050974fb627d51ff31f83de13e6d4502bc2339945f3fa97d37a7c76711761b55ffc938fa1edd4e462e3056c6e823aa992a72d23336f92f8f2c532dbb1c2bb9152658c37f7e5537486512418be32d6330d6750490f43815896d3308220d22ae9891935db6c2150c52513bbb23665fd65bf59592afc4e2f35812c1f15cbea1242d1e8cb3bbb363682f9e29e4f6f82f70e8912dc7c7d410b00f2ea750402e370fdefa804fa00638dde0dcce7bdc4a03a2857a38ec5b5617862a8840b23f4c9b551973be20179c7a59567761fb3111c990841428dbb5296c2e7bed7a725132258fdc03165beb420a58836047dc5e34cc027972628d51d8cf83de54099eedac9f2e57243ac52023cfff3fd17d21f0b9c06d1a4d2dc4576024a3a6e94fd7349e3dbccdf9db6ef2fb79c7d1f0d4e96841902ae44dc61b00341d3d577c155363192b6700ee20ed68f06760bc5a0b5732579915d214e205d97364556595861bb9743a6223a153b6a622884593cfef9695fe04b363ef2c463b09564f0dcf20b3bbe044d1e7ed81a59f66362118e08453def726e7fb0fccb09ec2b1396f3bdb0187f588de33a295aa56510ab8d4dac2f2b66a41e20e74e9c4431961f9e13a985053df1d455b9c93759825f195dd0a66f9d7b538b39a888c6a86d09f88f9f95c6969b5492a868e321049806ca3babeb1dd7acb6cc6352d30df82452911c4becb17e872843634692fd55ab6ad161ec272789d630dcbe7c831ecf1d1401bf0a5d8d8d26616a6870c97328b3efc2531d52ee0ad22151dca1fe4694f972f5fd619f10b7097cd749551e9c6269176b0bc800c963d9d27f1b837b9b035dfd97986e9809be047596683accc84ebce61d809a2aaebc1e014612d4cb6dc3f4c27233f0479ed9cde5b54e746508b807bd8b1b9330f797ee98d7cc523cecc73094f31406e3b383ff17db8d4025c5ce9dd4ab59e805049a54f4306cdb2fe3b14a26974cd1f1b215c3b2492e56c04e812ecac9220cc03758a7343301ae8afb78aa67a2f0bb9b2ccc27d57e89bc565e02bffad610a75cece2db9dd3247ad122d7cd9ba2690c65793f90a805f08ef748377e9f917a1636892beed0c74b8d6c0d89dc9a93113948df7d6212e4ffd2571e0c66149c815825f311b6469406c8709cab23c53549ce93895d307b6f3914f63231b441a1be13563ed6f310d47ce3bcfac217de71ccc03f35d2ea46140e2f601d33efef30c1ddfe5156a69a5113de7efc2b98d86fcecab175f1c99ba564b57bc95ee444311f2e365cc607c4c26b405aadf5aa915e2c577f9ec1d96cca55dc2f1084e0988b2fd2ccaa7a8e48194bf118e9bc040f24475e424b158974d1595bab3f4d3fe4b02d71cf2df5960083191811cee4c0b870532b1c1604e7fafe1b7e863d8837cc7d47484de000acec5fb4033e34322c888875f8ae0c1c0512bd5968b60fcb20be126162415555599bad9c436e7868aed933a264cbde2be91e597ba322fe48b890a93b14f8e363e09f4ac5fe627851c9c020129bae8d57feed45204cbf78ae09b4f7df27844e5aa19fc0793a30b5feb108ceddc4df87d4cc7088278a994710a9c679ef76108351059a8de532980bca32dca4402ef15fa952df71699398d715f8d0ff4852f5394f763bac238f87a7411f901b1ca52eddd4037cb5dcc7e004acbb2df5aceac40ca188928ccfefbc05cf341304d83bbe90cdcb321fe4f36114dace7744bc29745522d2ef524a19a437aa521e843fa7031e116161d60c3c3cad5a823d28ea1454c608089b5b49eddabcd3d0ea7f429791e02469d2633c7f7ebba2ebc1895d0d102087d0dc85df8f76650b10404a6eb0d2a266e1b0000ae732f158408168fed6615600a9dcb552565af9d61b6fd81fff02a18f4e50073422fda85968740bc096244c8515f6efdf6a73a4edc7f84810a96cab17cb347cd624750afb0767c5794aee631ad7a63b1e70a8139905c6ddb8c8c9bccf3a254213bf2af97e50a4f8dd8ce0015b8b5cddffa336509aad97e08b67672b8a89aaa4d93325cf94150f7ea4604f0187c110c64f6116dffae1399b514f5c44b1d0ebb8918404d73059571d1b208d7ba988b0ea64024c92cc3e9cb3e68b6ff1ab158cb9bcd735708c0fd9c16f895646c6b9c23696a614d48b0a09a707bd290bfd7aecf3d5881697e9bbecb97b99dbf57a14779bd12d1e06c9a2b094ba1c2b90ba1b24f70f91e13a76f68032fd5f64f79461a2293364c284c6f11bc36630d4ba27e2e95b6dc63285eac0a4d02c86bb199b853c9a20e34d8e0f88e7d0ed6dcf3b6dc50df3a8cf3b0f6b6fa1ae3cf8261a71c0e0d5ace9beeb5f7929fc1e1852203f51fbe233fe6ad20d1fcfbbaa764f4acdf0785d85e5cca2a5a299bd858a955b574fb3fa135ce68be3649967d770359994c3a7cf59bca04592353af38aeba6adaad99d36a3ac8be2b1b17487418394852f1ec654eddeb7b461c5dcf68596ed0385178c150161ded88e7c22cfe71da44dc5048fb49e8b0f64a70e99efe99f0800666896437c461005b0367b70591e92ca7549f6a0eb25a825dbca918126f73c03d43d9059cf02c0542e8ef1f30e9fc9630ebba4ab27347110809d2be3b6f8f5c9976012d7e974cdb1e3f2bc7a5a78a2f0868bf4c6d8a2c4426456a3abcaa4ad3477ca924d69666dca9152911e27dce067c3af3929338ba9b0f972d8d36b48ebaeef4adab5f966d7eba172e5ec7ba1447d8e777861a6a7e19560742d073638f5a8d5911dfa6ed42292191689e6d9b28bdf05a1c1c041b84a334f26dbb0e7c9a74dfbe2211ecf04a42abe6f4fd79347cac2cd309676383f7a1d3f4c034db017661c57a619c4165d13182ffa207b82bb3f4c7b1e2d81e71d4ad75d0a09434929cc5f5b5b3ad91da31391a344859f686db9eaae96ddd164f89e0016581f2dae2a1ecbd301a68c041a30b9c1cbfb40aade8505048e2c9b62aa3017a26273a0a20998351afee0846f483437b5bd3057e4bd90a12f10133d11d6fbc3033cc7ed48876b0b56b0ec01875607ce051c692544b8911d2124735082c9033082265af0a9da728cc49a02c67f1b1e652f77fcf56a7f007384dda568b4169866625257decf60c3b359fc259880dbafe2aa749f5738b5fbdeb30beae34ab1f2d6d1b1b01f0510c07f7a4048d19a5b17d1c81cc02db525c69ef9db5dc5cfdbb84322024707ee423c89c33ed901e959548f842af80c0f5eb4509f4a402ebe0b85d398b4e1a2e3f5c03111055628132e3d5fa886fa1ea6dbbd30c0a34e72839026c1332d2bfe33d77b09f381544af9ca0fe9f327e7d2e4846a6f1d370ef078f3f1b26f7b8fbd876ab681375f52d84a7527c6db441ffb0265583d5a9be22ab834748cda1192748d9bad5ecc95d11413dce0d70bc024ba727bbc8d87975dd8a5718202db3f59a8ac12664f95396227286c783cabc72e9533283be8748d29a72d595ad41a4f0de11b7e09591841fe9d3f247e1f5ec8a02c1a6fae419f28da9c29b30dc939e8a54637a251442104bf33624fdc6634a65ea0d86960748b1a072bde62ea77a477692bab6611f27443213fbfa58cc7dfbe0fbac6a7f9ee0b262ba042ff38482e534ad9a557628e7ad1dbe2c4e356fd06d8f9251c79765d67435f5bbdd0e22b1dfb1a90223726d26a6e7445d867122ec7a50485092f8e449229f04f06fcf900b6f49492665522f8e0418a3f15a2212e1b889b4ada04b45342d65976fbdc1b1f4d4fc7f62486b2f2584e2a34520d725f8eb7e520302ce68d188d2a1b49667ca7270158f386dc011e75671f0def640a475f3f55ae10e7e3abe3115630a1621a098b2f89c2601b2351da0d67a9e9e836c7078fc244a4cba50e2b35111547203f85116d07e9168dd96cd980352098e92472e743176436121950a149d721ac80a001bfc40bd4383a3ca32889f5874ffb6d96ed760217826c4944f27f1bac66b546fdcf239583a50fe9c2560a1fef0d4845f93d4f9d62510012f09963cb7b4d6ded7473a488738d90969119868e563f348212ce4bcdd70b259a8e02d76e16561b38bfc8f42b3f85039691569da0547e04199e9c1616cb6baaacc03959bbd532eb30dfe607ca6b2cdd250f590a0469bb07677d82632e16dbb85f89805d478b17d5ee2aef5ae1c5af51088ae34206783b9324cc4c7804d27ebce89f7294991eb1fea6405b9e29d3adef79c21e26f5b41e51aea53fb3475803d73d9f1db79f9d8a2af25aa1dd814fcc87488f33b0dcdc14a109d337e490d905db02318e8e5fa2bad2c4150ad08be52db274fa9b269bbc7ff4b8501e013658e9e14c9058e7eb9e2dd712710522f12cd977cb945874f45af23541eb4080dde92ceac4c66e795373e8b1cb3afb619c223b70b964618fb0f2ffe1b5ca49300a277c400e66d5b34c12e6e2b9e93bc259b8ef2217d6fc8bf7c8e93047985491631bc4f2eae2220b3f61feb1d16963ef66cdd33656ecac12c1ad75daa53854d9019e0a1c90795785cc67c9d64eb6c7b98552f4e922537afdd1dd36eccb6b456821bd7ad33fca0578a0845f17d272e01618e7bbb1f22e0fd161617034a9af831cba60dfa94477d80fb56c9f9755c577f566443e30ee511383fed1b3db737ecfbc2c1e9bf82a29d66079822bd8522ece0e09e54442112401cc4b7b7503fd4b750d16b8c4c35972aa66729977e36150a473474d849e8b9796dc182b75ed4477bad2fe5bf8b74e6667410b0c16be1167d27dc51672a4011a944d5b672aba2eca1c275c701833357fdf6288a25b940a320ba9bad1721fb9450b2bedd4617dbb5298128d4b04e2d0089a9f391ff407e7a4c04ab90a3d8b1f7752138f7e6031fae28f7c9797270d6602ba0a3e352a851a7f409550cd743fc5ea86426eed104f0346e05a8c5ef01b505db9b8e47e9d123f965b88a49d8d894d7ae3dd73b359b8046c892d5204440c492e2723da357d3ad6309ca74a8c67d2e3002f9f7c03bbe26d30b2cfd3bd0ccf5773f17a578be90e5d416289ef908d0cc51de58592f1e4be50b9f0dc0b59df10f940e9539a1de5f72631aad2be3efc78ca5aa76d4600bfe211638879d97fb50345f467cc411a33edd9640c371bc3c1452b301eb6fc64c80cf742a716859b8a7188fbb095871b03d20f9a5c1f5a3a10d424bdf7dbef3eca11470bd229f4a0876b9452180ae1fbb264fc6928f2b4c332296f3e1906c5fd548b756d404c7b80315a3f3910c17ec18ffe830d1c8441b52397f7f44c41f9e8ae2c9b8235ed2d7d3cf030953b9916eba20920fa70a7b99f2674d738f346a63b1272e277294fa1783bae489cad53e224d9afcce28a7619a337ddd6af9a23f33331935e1597db6b73fa8c1a25630161c78777bd444bc9e606af1d2f7d42ba68c8632ef53b6bf1faa53a8232eb1157ad78b11cb45f4bffeb36c01f2c62a1b6e4e8648949476d42de792349efeedc88e20138ffabb2e1aaea756e0be99e2a6001fcb3076c4027f991624a3b865998a055ee69a770f51386604022ed54d178bc00046818f379e0c33b5a4ca0d98df096ede680b01570a91fbd22c4aadebc03d3ef789df03ad4445111c021ba96c6354ab4498b1e483c582bfe57b0a2939e2348733448e9ce1787fe5a5fa9a58463438401954a63b0ed7dce97ab4e98e82215d879b11f4023db7038312f0d8bc6daa078f21cea4246f3f1d015e5ffc40eebd8192e756c0f1e3bda2a862a68f0a48ffb812afe64089ad0400b4c2467d7eede6fe41073a972ccc06a853902bc87c983a9b079bb92a944e2dfbd3203d90e1ab8c4107a75125e76fe7acc6d9783aff0bfcbefbad62558d863881611e5b09627fd23bca7a159ed2e0063e1a7d1f30ccaafa69004fa42085c5533691116e38288eea448280c5863f495c451f1194d33114efd221ddee9466633a9a6255eb7d8e919650f7b6d29c6c1809dd062b4ccb02550c23950994c45f84314e31ca7fc1aa41599d3449df91c5f5d8959f846f834f3bae48d56cbcc05ff0f33bf9fd3f6b67ac6cee03821ce0507779cf80ef3cd0a4af483ca5ccd4d7a60b24dc338c3f5b8e62faffe7a0ee511868534927d65e97428a864e6907da1573d1adc4667861f65c80895af24d26a0c2fac438419beeb14539dc296f75bd6ee0e206281ddced5ffff014bd80174c6298e7b76f45f81b9121300a051f41ad10c9214b98464f1cdf768b31a325c1b91c519dad1abd137c4f073b1d1863eaa09837b144f3e91c5e660d5d064955d89aad05521465d4a62b6ae301c98d7af96ab68b2270dd26e48a4f643b097e12d2e7503912977f15e24c7cc4da3eccee971bcbc25e72cf392e2d9a704e02fc63b909b622db815ce397ea8ae123a9a28145064a7d59b81789feba33654b447eb5249986c4113e70d44e5104deafd76ea8f7b80a0c01da2e28d1f979ed526efb55e945e6a049e6bc0ea104676fe4c3599ecb797f7bed002fd1444493597f9629be8dff27d158c58f425b91ba4f569322866fc16f7803b16c1f203bbfba489f24ac125ce2ca6087fc5197b8477d5704952ad26c495dbd1498370326894a48e42ceceee8b8fa21dbe6171216bccc66fecbaf840a1912fa77fd9625ee80768dc012a777c81115dffc3e1197a2feb5d9f64db208bccaef0c897f2be02c1236aa90a7ec28ec9e72a4294f4b05545a79a22a17a23274937f0fd04a4bd78593d3445af995628b5a8ecb3e4dcd075438022fb75131ee92fb52cc7648268e4236bbfa8731953de96652b83ebd91e2017e47d85073b91eaee9374899de291cb561456ff2c695f40a7487efa0f87cdbd431650417563e97fee6cdba35c635f834256b3f8b8467f34da2ddbaae992d79d4e7d83d0130b5efad163fb386af9b4f280be596920bede4b45d01b8d0ad25cb889dac5e35729a73ae0ad1c26874d5cfc8c697540be62eb1f3f6d0649b2630c2c1eafe4252cc2ea7b98993678d4d376140813f81ce48aa2cb6af50bb9901235d4eb8cd261bc1a83f817b0d2c2d54c9b2453d4e4f4f0a6b4c8eabd446b037e637ad97abe5e40b0e59902756209a1a37334c0f659e3d67da178f4b4d0414f192b7e565d8dcbc008c74e22b33832745ccf40e865a46d7791561435f2e95a8f80fd1d9745d4564ea63d996ad5a252300d349ea60445bee74b4d625e574b3d82ee410b11dfba7f375e34f5b64b369290d31b2fdf381bfb0b02861f1e8dbc03751cf36dc4e170d08918b3d52bca374ab1759f8c8f462d3c0dce86b81d9e0db7974240acf1f8dd30c66bf31c7d502ef57d699286da1b7be180ff506b1e1616efa6f8d364e9938d29e46b220b72788b60ee0a0d1009a2452e27e1e4873185066ec1cb7cb3153b56e812e890cd8fadee533d67fe803170959ccc1ed41fb3dcff289600e4397d4acae8b3b1280d7136145e2556d371772d2a9838a17faee8e0d56284d0ebadf84d6c1315d9aa4ce12e5dec45e6ffd951d57248031c6d320a2b3b362321229f3169d31240bf63717c67b25d2e1240118fbb6345d3609d176ae213c607ffa7583ce54ae677fa7476be05fcc468d55c20174cbb58b866aabc41b246e748b114e922e1faea73352616f43a8ce808524be64aebf75c59aab94735412ab6a7c552d3bb7e78e0a3a03e6af93d24faa51b42c62d01846a4524635d7dd302d1a75bdf061ad1901e5971d2d908a5a312d86d7d3373e6051226aca33c40001a812a23371f668bb8329f2a33998b79fa3611bcf620375d119e5e9997384324176b589f283df4f7f75d15c57718a5bdc3bc7688c10f8ac128ba78b9517c84accde6de0e7ba77052802dc0dd5bc27db4141474d2ec45e3a255100892359bd258fecd087c8e40456c5402aa43c63a3ba7fe82f0e903abc4c2e047223ab0ef3a8c8b2ff54eca8f9488a1515d0682a845f401b44804783c66ccfa311eb10e796a3b84cbf80cc2280407c309c715f10ed6bffb6c60e4297a2bfd15a5eb1d08f45a58f13bb4ed15323974fb0ffdd85e41408b9c6d158b6e02999487b2de7ce735cf97ba1787f5336ebace4f7ca53b6de2c9470115f7f140bfa49782dd04a5600be7a6449d6d4d4dd16dda3695a1ad1b38b391983a58eae83ea2e859c55448bfc04341a7e07d536676eb02a76c104bf06e45b740b9bb4e3ba70e8e6622cf4c989bd89480587002d81d2db6c6da947521393ec8a3dbf3752403af48490ab3b6ba27831bad23fcf15192ccd104a011bf7e24d3162697c672c89019e013989b5eb9bd5b154106ba87780f7c87f433478bc51e0e0344c162d09496f8fdbdf37cb0d3b36d6342491f55dc8a7e7a89cc0fb9e5b2a42cc5b6d5549e68c39d7ab810980f2fe1a99663d46cd26f9cdb7196794602227c3190e75f4e0d59af16cbc771bfbe3e3d503d39ee82871b7840f16501c04fd01bb61a915ed3c7313388add2f2b5d069242b18ff1c34f579ec9a21a47be3bb320dad5b1fb548cd869be815e8cd850f551439f2c1cd5e849683c2099df29a8aee9f3b2a4dd5e6cafb2cbb9074c0be1ab00ef1d506efc22398f5cb7e12bbfd5951a9550b1d4cbf3bb2238684265c9da03ff666337672004481869f6d65ea2114a38c61d01d2ceaf47caf7e47cf4267de0d3f70816bd3ee82d34b00a1c12362fee18c8c7a2293de8aeae2e546d6b1f386705effa19893c201f71873a6e9bda1ec06b7a96968e97fd24400630ba39f00f20706b3aeca17f7d019243776f73590e5ca694d918b289b2e07ef7365bacae66c0894be3f62b6af25c4575fe4225da40eb0176010cf041fb323a759051a32522f6acad02a9c57d0c687bec586b00361e47bdf2f9b708fbfa1fdc99962d1f413470a91bab3e949cffd0d2481803665ea532c0443af0afd400d5ad14ecaa595e68467136cfa69ca266b88828e0bba5ee83b836627a6eb9e27e20f59184c7f7c03bf6ebd7e34ff528eda020c4ddd5112defee2538356fc27b9ab08aa2783661d0cebe0e07ab568ad0174966cb4ac88decc2281b3d8f8d6f607fc916becb52a7ef862edae5a79b1658a5dfec7b98f4162bd78b7c457cc85f067d8815f058f84d1d7c68c5fc4e9acfbb7d82b363565205075b21f87227c6e166c2c9b9415d5c4fe20ec281a2e3de5874c79f17de7546fb142ec4f4573e03f98cec92bd31e0f5c071e6422b75049b85c14a4e283f47d670d37c36019913d0e92fbfb03e01f22a0ce266a226e069f5a6755fc68e755e869acc927fe2cf12a6fb72e967d82395cb1b89fc75e1b84fcea4bc614e5688a4f6f3e4720d1f63cfc0069bd29ca9ceefde6ccd80a4c1f6c0cff7f1c492cb2a8e1a0018b6549ff721837721a33b2f74059d1341c40c1fdd92a3eb42d886734ea29eaa49caba9ee225f0a97514cac7842c43ce46a92a8cb6e38d1916890b01ba5c619d08559bda08876a232c30fc0a9aa10751e6d59894341b2eaa5763ceff32ed399daf67873babd5b797f87f04bf590d521cbfe520e255895237da4175a0e4b73eba4d30263b703a23132374d600c68610cd82699a0ee76b0e32660dbe93de9ace1dc7bd36723208b67c8c737450dff953811daf0597428bccd272d16035981214323a3626b630675dd226beb703f512aeeb807aed7c1f279fcaea446e2a33fa35ccc82c8b40160883040e9d5afa2a4aa79fb8737805140eec9a0c82ad676d97b3d4001fc0cec4fdf4adaecb200319621e66a521ac1349b6a369a92fc9ddeae5b386926cf7b6310c573cd798dc3b7a6e84e691bb57e0f628dc00c3f42145fa0d42f5f7306a63c1027a6d215706958222db4a65695043ec042ec757c9ced64c3b78eafe704ecb5fa6fec5fc880ca9c9466d334947f6356bdd4e24b70e363a47617cc5e041c82024554bc7d1bc6b3218366c7140d82895453a1e8727f8ddbce47cea2335991dad56a4099bafac713bc5b947805073a20117e6e87836fd10dc99634f6bc67832868a403be293a0e17cc421baf756f4818b57c9ea824420556f4477ea402a25db5711c8648e61a294d490e67e58be79e1ef8abf890745eaba13874dcef0f5efc9733cf5f4fa6b2fc48fbc936d787ff65752894a896b334d41818d521fa47ee417963c99c2e83d2ca987244932dd41ca5e28789af7bf3f7b99619ec8a965adcabfa3e9270666a368315c4e7f937ba6463aea09db801cf6dd772a8f8a62520fbbc3bc650d6493f487f4ad364cfcefdb5749088b02733a0c5f1686daa55590753a8971b2d9b258eb109bc2f572e4e2054758451d20645fba6b601868395ac86262657cb50ceab11c710f91f0ce74abc4168272e68328a35abf2779f7d8f2244e2513bb7fc3ad29941408ad9967fda8448833a7a7db72c86492b808976f2878f9a1e438966ca6f87f317b4a5a9afb2ae60b2fe66491df55e2898202d404ed017f9aa35b067f828f4f399d3d924545876e4821d841757140bb218614bcf10964c0c5d104a7474f80370da54e74a07a81452e73a862e9b86a35c9daeb6bb8d0396b42ca69b50462f05e710bf39649a78590898184e4014d5421eabc79b3d8505aa592b5d41cd467fc256e74f97d8aee6f3f5d4689a7ca806fd00ac1ef4172c862b8cf4c1e5b81adea3187852cfa866ba74709cede2d7f08359391eb29f044cb99c0beb4872b4f5810da8b4c4ad5717dd89c3c60ad12109d4147b007724629fecc50f14a435a8c981b27bb02feb1808d728e79ded4d3128ae9ff1b9fa3082483900479c1739756f5c3933a5ae6a0cf1e5ae6f74ae4716f367ae59eb50e044bc8f6def38516b9f61ab9043c08c8d4e93b86272567a316f0720a567f5bb259348fa3640ea7cb3ad1f9b3b9f99362b7fa34708f38f0531d6153e43a1e4fcbdeb36dffcbb84e0b61124e7bd2ce9b37bdba11766c0fb235dd0f729f9ae622509a341d0070631c614d05c7b4e86a3dada70f1c2e0f1d7f5953b76332e625737901242c40ce06dccc7eea69cfcc5ea4b296e678ec45cf7f799cb8e2162c5966a261d3f098dccb8581300da664c1a7e71900788241c23ba168d05e8f9f0771f6a723e129c2fc67c326148ca4464c3a514aa3c59803fea043889b6c99949595f578f8a6097185a460c064f6be0bc0eadb9a1a51b998f29c147ce18fc03977e1dc4b0b5af226009e6879ad21fc143cdd13471cf48b78d9541a421c357cccbc8bc1ffc395d1df72b8839aca43f772316b457da08824b36110f4e138cad9e0c14868a814a1784a8e45021d7c33d68bbb1de34f326f889db3c72d7e4eb9e2c0456ca4f71abbcdb5fbb3345c7763c65ab1a341956586dbb961ecd18b0d9c64e24c1df9064d4e6dad18e655156b3cc291fa3925fbe8adf2352c02ea453661cb8f75bac27c76f7f48c772ed168f0930c2a5a1c232474cbcfdce658447ab51bc67a1e78791c3ff607550cb55fbf7b1aa64c0457d82b2048c88fe4027d71e60fc7884adecde7c509e045f3e0629e42d8c3840a89a0d8901a0a37abc8bbb08ab69b80401572014eadcce96bd6cc10b077ce6e430a920c1855f9d7250ac1255459c34cd452c57d9b40059099f25dad2d627d9afa989bbfcfb8d12ede3aa38c8c1f02c31d459394fb75dfa1d996b955b359809b0e3277a962a75584a967c27cd0f1ef811bf22c0e3a270b4dcb791ffa05d9589999bb0d5ebdc22f0ed5b8044a0f8b4d935c35ff1a1b06accb5d92c87edbac178ff724d63e65b4d92b1a3f5c6628ea3870bb338b09254e9b4500eccdea9571ec2bb443db9f3fa73d509920a1e25bc7b877ae4acfde45075e4a54e7a9f1a71009abe423faf9c22c6202afd06e9c7d73074b881f333d6b2ed0b8289ca4f2382e30ed9aed5d95c4ed0d190da2b2fed8c53c2feafed1db6cfe7b58099864fcce323a965c66bcb08ac31f90a9edf3fafa9ba5e123199fc38c5ceebca0a44ebf095f7b6bcfa46c35aa0d6909dd74b69792cb374f21d59e6c546f8c48de89f4ccd0de88a139e9993c31a869b27750a8b1772672467b60dda390531416a11b4e3a3561edaa9fa1a0796d777f6db2515f21b0a04e1da2280da61c91c9d4f583242e8dd3fd966c2017a1cb74d9e757aaa6a5086e2896cd3e621d9a90de2541c01e5c684f57ad991772d40e37b7f52312b58ddf162e160ca6e431090c2e9cd85324d6e11c0267e4ab27b4cde22a1c8c2b873add4e793dfa6d0c42fe93f9f8142af6fe6aae8111d64f4d4d4e8a95631a68c6d9069405e9d8eee5984deefe6243d7c13a32647933295880a4c7d602f817eea5f4f62b0f9ae2055e2b1919987b508453be196788ade549a7e7cb62b1292597d4b1c56fda845f3d4d18526ec0fafa1410bd8354d8607e1732c9bc9024926a0f9b1bb44ed433bdd9b8ab36e7361f51a367b7bb47c6dd71ff54347696516330ff73bb7eb3447117da41b7f620f8b02fe3628894ebe8206972ddc65ecae34aee245d7968b3506f0e653cecdb9d6ca1b7831c47a58ef319afe928f7c6beff2348275cf8d705ee76431a52dccc20646b3af2b8d7794616ee6fc75a9331613ff7e2fcc50723a528e4b8df6e9f3e549ffaf9127042b0e551d21c49cff9e1be8476b105745f5cc7ef6a7948d6e61960cace5e1b7999c528e8912f015463ff9c1de300dbcb641a091fe00f270b00b825d054c3b45dce35036bc66b58ebc75e381b72bef1e487e998ee39a42fd6360836d1d3df422c77c58ebd70bc3a34577128a1240c2619e3d634ddd52ac1892c1e7f8cc5d6dad5af54305073d2873afcef6d89eaf91b1eb7717f02be1fd224a55bfa3eb0e9786dbf78e80a81bf0d9575c7f3744cd6832b3870875b8d9bdf97ab77619504840a3080dcec383a026d0da8b38c18f41f06a9e7af2ca6896eb41f57d75c181fd7dc5c4cb27a7dfb055faddf8f06f4e230b1e73e0668f265277e54a4f84465a22b58ee4cb38a06780619b5fe425b755756620a219c5a299cb8140cc4ee1d2e7d13951c220e6d9ca6521245afad4ba733b149972235d91026fb52b36e04d20516c0bae318d83e7eaee5a48f9c87c43dca7f3d6c3b2f0eb8ea5eff90066d71fe5878a015ead7187602b925990f69d363678bf2b066a43e3d66bb3e8ae2144ad90c77f5c848eb90d937a0f2e909e2c92de3c7f5614deff3001eb683809f12c98415f241138289042c1b602f3c291ec9cf34d723b3fb6283441bc2ca11bde3d62ee09c31abca67e9506518c0621a67bd1c80f3b436271ba5960824cd713d4d897ccfb151917a9284942589304c25e454ccfdab0e08c6accca8b7d1cf904845d568dfd7361ca5922319afeba9b8fd33dbb7a078244d42405c984bf33cc4b748be22da10e94e41743bdf29b30c9297da33b2ef24eaf1e31df79e22c9c35822607f1c713e7c9256a3eb85fa97ab464e6c19c40dcffa993594cd41e9b969d2e74ffc7dd9a37793888bd0e97f857b67ce431b0e6de584d3b9d66974d3a3c2e1d7916fa7292a4a837a42f21d90b93daa967ba3a22946d84f4c08e9ae70984f269b6c409c9a37e21f3c9f7f32b432f9e3ff9f7f745f8e1b14502d08c13a729ef629f5040a1d7d9dd4ff057e74ea640c63a825aeb04301244abe48eaa00845188e639ee60542f11ea0d12d77d4dcbfabdbbfbf3edd672af840bf70bf1480f445d0e9359e6b03eb14aa3e573dc366b4691e07d3e42383071a28a2c84a116775415ebde18f9147c8828c8a1caa5336899fc2b6949115c08da347989073f02299f2b87b26c15589a16fb61666ecc15e3695aba7464f871fd636dc191223f9e3f008dd9c93e8860d705e41da7023aaf96aa83f49b24dc4adbdabeece96c14b00e2fb3732732984bd47167da337f1dc8d2e9a3fb5fbe63de9ec7e3385931be25adf9da4a137bd96bdbf4d8a9118697bd7a10472d4bed9e039d04e8ab0c11ae1e93d017a7a7d8b7a6c701d0807309e590e98b5f495067dd255a3e5bc723805d0d7e411baa14ac1aa4d89fd4db835c3cc67763bbd1d8087f366245dd5fe3626cccda56ba393514bac6d998c0a75a44316e9acc0f7e34376e5e6b6f2bfa30dad9dac7abc2f99f07a9099792ff1215940ea0c196eb43cc3b8722db85a19d53e4a2bb3dc0d354607048340cb9b044648ec64a92c62dc1edd2769f8d7799e1d272052f6531ba48269336fa91010be994ead6ec834407145e6a5f044924fc643462d8ea97f743d2d011b7030168e64287226f7d8c6d39783c8cc1c4988ad17ee7e071664e2a558f8633bd21723e0a450be26d77d953e69f91abbfae4c1a801a5a06bd33ed85bc71334da819ec899a4f8360599da0324fe99416eb3a3151953593a2ddf3c66595f5f0864e852e5f7d72751ed8338630560766f0fe67c47f72c73404c78313baed1cbef4ba0b296eb30f144879dc254cce606d7f24a4051981baf47058ffe8099f69dd7c75a4551d7b7cfa620232dbde983c366af284b91892b14eb76252c05ae3a3265ade6e251ef51efc89da86fea016f07a8fefdc2dc0df11fff0613b21d394f9023fce3cc3ef84d1ceb23182b5b751df72d8c1d88428262406e1425cfcbb97ae5b8c9171f57a09cc7a6fa6506e69703ead020387a1b346da8de29a1a48129730ec1188906cf8e1f8141761c490da28485b312e697181b170de83ae89bba8aab675c4133044ea4c75ba585cd439c4900a019ab4c50d70bf59aacdfcab1f2fe8bd2880be0c599f9e8d425d11ce3e7d50b90809199ea6e4b9183513e9e32f354af267cae53ac36b6f08fddcfa418beb5b64848044ee0664e53f711135f08e3fc35d4126143746394eff3599eb5e8561acb4509292ef31234f4a7b689ed168e1c7b9694821f6bba383c258891c85b9e4a470fc15a071599bde014a455cce3863d38f07e3b528c358b5cb7dd782608dff254a5c94f4d80e97ab912e29ad5957857e8e60581eb7a00a6df5e9a80297539b28929b1c9a27d935e31e1f7a41d885ed5c0c65297b6392edd5fbbceefba9db26c554593bd99109c329239811c1d62c643c37780a9f4638ebd842f00210c10155bbacf0b3a41d25f464d5a8a77b23085394b3fa4bf60fbf401a72159031370e9f07ef4211bed8e3560aa2b56837840b3e501a7ffe1b732cbb2d33befa9acdd6a1b932376b115cdf7d460c9ce8e98323c1ded6ee7c27351c8e8e6ba67999a084c10fbbcc684788db29a92a0f7f8fa9ffbe13a017488f8e28ee2989f7c0a0aa1635c264f1b99260f54e2431a52f2ca7cde119d423152ddea5ad9881df5224d9b819e61eddcbaf8eb35caf9f2f6629555803e5e364bfe3ecf459ce4d592b64e8997b0ba3fde8a91615527d13725801b3dd6c295895a1ac8a18b64894dcb9a359ec732cba6094a95dfcb2e763d2d1af8f9cfcebb7df7520dd96426aa6e4287f78b96ea6023f4bce79b884f44f83294cbdecf15e34d12fece048fbfc397174273ca66a371f9604f5e20a27d3fa3a67cb3c991ec51cf9873592e5b9b0d9b0adb4be71c8c523aeb27d71e5b6c2fe55c4b5fbff9a263b349a6891ef47486018b77959980494e8f5082b55c1ff56ec94d5fe1913de59ccb0371f23083c5d362122ae98c1760410d07e7954b79e5dbcca716de49dde9db9357787668e746480256b8f049d733ab0c23c0dfd2e18c37ce01b1b603a9f1373336f815947cbe0106654828782afcf603b46c3b45e85d331d6c0f2964eba25cf9cdc14d69f9db6720bfe259795981a329094695ef4c8a9bccbf2c9a2e36a63404c058a049fea748d05caa4c0142b377c5e611c8cb5854dd099d049cb436c47a89e9d5685ed5afcfc8fab65bd45a818df7248a13e0b04f581e636d191eafba23f8509cefc683310e9ea90b59cdb22bafc01956f8fa72620dc4eb5526e889234a6ebe648a6743fc1062236bffd984aa63c78c2f9ef30a3917b37dcfca9752856adfc74841952f36943247a3f850d588d2d9624b6ff39b3c3c56ca3a46ccb8212af10d2a29493ba745bcb1d8e9138c909c4d9959f91f35117ddd8285af0a6d51c2aeb428a0ecf12f1371186901489bfd88f05baddc3b12222e8bc282d17532fffee39b5437fac8a4d54b0b0a23b64b209c948499215a2c6e22fcc34c41ba9db37ed27af2f817ff9958f2dcf78736d968a3babe79e88dc0df9048ed4b67b5e2ef2df91b716dadf8be21d1faeb53930a3c8500b599e6bae0d822c6e8b3a2ce041cb80ce5cb1f14f9298233ab98c34f046f31decb5449f478ae79b28b4de562e625086c0604b36a2a54272586fa269c84d0d3a09ceb77743489a12a6ad6079837ddf490fc70fe1f9522ebd652f7d22025c1f3bb05cbb26e2efc5787389eb7219617d9eb225f4ee9a1c12b2fd734b81fdd375a0fe8b45586d915f4aa987bc9cc6cbca0f6b29ade2ca8916ddd03a8684c9df42b7cecebb3a866947c300d2ee83a64a34afe2a85188ad5dd1fa050ae69bbaab58ee55744d4f86eb1cb5c2071d16e15eb197c6d3592505b2432a61caa320755c2a1054fd53641c868a7324c52374f9d9ad6c608e558f4f018db922eecfa97c67280bd8b3743fab35c65465248ee9849c3c501a5882719bec5440cdc477e33f318f792e6ea719addaf3551dec311141407fc80a40267d128961ac888f8f3fd225a6e8f3cb8f10dccb0cc655bad209baa5b664f7b3d68d0ce550ca18688b543fbddfeea45e595b12f713bff44398fb528257b785ff8a77e2d7400e693e7f123c92385c82feefd55815d2c5192e0dbc1f368e1f614d0a55db25378dbf3ed529e1640510ccfb01ed97240bf5ea4b086aa939c61f9ecbd467f247462c0149a1042c93ef1e18b34d8a898c8242ce4510e64e636ea57b4b725dde8d3be6481d095f9485b6a76592f64ed26121e32135e59d74b4454270f8ce4c7e01e144766c252445cd19fee7497a07845931f5ce4ed97b9d745e99deca1599dd3d17a0c952883f58d4d7ba22d4ec15df6bd6dce7ef03866838edcd455f71f7473bf7d678f3e451181c679d578c31757b532a0a06340427e535b4ed520dd7cac73afebd01366f1e63999964a64434f3d54de95528b9442147192fa184b307ecca159a951590b7e469f9cdcb8a9eb056a42df64b52b1a76b02b665af37d758707e04dc63bf9bd80d85e1d7c19ff6af69542b3750d6c3b2cadfa50be2906a043db20897ec6ca9815f6a131a6ba3579d8055d733469f270a0367ecfe336d14b6b04f91b7010e2be0f25ca7b7bdfed44244dd0a801961b9ffe2989c09e5cad9a1df411eabcfb643fe0c91ff368a2e46694acc66486761cc2ddccbdab28ba2e369946694333246ecdfb6df912e658f101af2fb21f0640cde76831fca61f43908d3cf92dc0012b851a869add6df58cc374de7994537b6b4da19bbf2418c8f1d2f84a42edb9bf234d819a61a8cf2067de06eeca68e7f43d24902fb61d34f3597ef5f64586834ef9539e165004b37a898b0f581b406539299445c860dd8496a732261fbd7878169f9eadce707a3ed979518c0ba07f47f2b4ddad3e0aa62802bf8409d095e663d7f5bf7dcd6cc55ffd814ae866bcc4957a71c18bbf993241b63f9c18ae2e3c527429dc4051c2972480e419d93c8890b9def520c3da59bc94efcaa5a2d61e4055ab090b1d42e0d273f2085f6e68c05e6f4be770261800f00767b51009e2917b61bab6158313f601fc69155e409ef8e7a286da2df2ec277123594e7217862bb43ca7716976fc8255fb8fdf59172ffb5b4e16db1681b21e1376e679b77fb8a95906e29dbf7cbc4226aef71ba76440345a1c9c69ce81f81c7f8ca6a640b21ed731c1fadb02dbf6d1a05ef7bf53461b570cdbc63ef4aeab0954a5586a8028d04759447d0530c62f5f7c51141a4316530ccfa9aad6bb45f02bd52a7f711fe535bc9c07a5ecc7a95b55417e82e039095a6c82f3f52cfe31b5c23aa1100eecb6c2dfdf5ec93b40f557d3597d831a3cd74989f8674730e9a5b0d02dc5a1e816543987bf3c750bc485dd78f1b549647e1a7bb0ae02e0fbbf94ac70ba306940662a84a00e0bfd05d2e2adc353390ade31bbbc1790f1f0d4274946d854635f926310060f00c982d4726406fdd2e5abd342590e8a982ea39e08206ed3b0cec577cb00207f831855e66f72d33f3e53201acd806a9882dd17315777feb83ae6fa313f498e8ddb803f941b51aea6b77932fca2c6e6635b2d6fd26a03f638b2f4746c8c994dfb59dd4e3462fe89e6a26e443177c77dd7cf6859faa48ba145fccb2d3ad7de459f7d535a65913ab58c76a91258d76696333369eeb9b38e4efd17d006ed1def36974157888953ff415a37901ddb142b644f15f5befb0bf13742890368377b8541830a25379757f8abf5c7f50ce460a1de0405543fa8d8ff45ee11b311e17d76e87aa504ee3231940caed83fd219dad9ef929ac06f91c1cb121925a9302b11001688222a48cc20c4c1f1d1c2faa6086d902abbf07092de53be1d5b0f99379023cd2a388913a1227391a0b407d50b26db1ef73334931725489c09fd47480bc5a45bab3eef849346531d0f5978a6f68f07319d969fbeb52d932231096bd93d34a8af96a81d54706748589ab23ec4def716813d4378a21aa3e2dcb87cbdd519a4fb1d4c4c19095dd73aad862b2e1a45174e50506976180b71a7837a3eed00ff534e3ee22ae31ccd8656c4e98616959c8ae686d7918e10c08bf5cebcf5d43ae0bc20624d42f45e1ec0a65c03ccd2ce524e2a963ab78631b68c1e82337858b6901ca3aada421b58766403d7c18912f1929cee0b8c7fdb1ae89b54eb0787a4e420c24d09fe36026d4f8fbddb831b6a217751bd14a06e9623f38b90ae218a49da371f048a3caa0bdd4bc8196c592ca8044fa53ff9d3e2083de6125ee88f73d5fd97384e3c745e7a47fcdf05cd9f47a547c424345f7c5abf10596b81fc701c76715cecd05256102bc7d0a982ef7c9594d8a3e07b931973113cf9b190f4adc4b4e600f34f577c62524de16b2a00a3b78dbcf0acc75da2c720e5fdb2a800653304a560c4294d2ebab55ea12f88eda101d1e318f929f99d8c3f78125c039d53f310db21217ee918cf0a806436f50a44d760d6caf7e9b3465e169187a93251b7eed78af836da303e8b5bf78372d38f40149309efc19f952681cc719662a0b921354684f81b03d6380c4fbb35a7f8b58c48573095377a83d0daf70d35451224ac95a86da1c6003f771a673ffa6ed66db19011ea988d3bc4cd41938a0554f0efaa0c40a7c2cbea8c3a4d0b81330ad874372f820a60ab39b8057e7d6effa27f95e84640f66cf9ecfad7d2bace391791cdfec899122b85263c632bc31e545009f34ea3f08f0fe4ee19f5c1e28082b108dcb468695e690d5b1973237b841625ab63fbf746c0534a50b8f51ae6786e18f0905aede1421342cf20c0773cd97602ef43f6b008477b6afc365ce6e95167fadfd2f01fa50cd8b7140ff8703d4ded2fd70a80ebed9df6ec85451b2089b1f4590fcee2e84e252d85667239f41d1fd183745f1308cf8b33366d577b916fd288ae9c717246011042ee4b457b2150207fc6c0d302613b2e1873df66ae561e328218712d56091f6394b169e084df521eefe99fa2a509fb863c060ff213a0a8691e57320622265c2da90319c4d7de5e6d0894966361c24e787c82d8faed8bf87a60bd18a2877ea9047a9d0ce6a5f8b304c63fb368c5d9932899015e86a94a2ec942649f818e3ce8940f85e748a9715656ab1d028432c7272fdb581df9113f05d496b4fe7a0b5abb08a212ea31f53da451bf1679eb26a4cfa545e8b1020cd82cd6896de20c920e05bd0bea115609383b6c5a790d753c794504da669b976170586dbe4475f840486f816a8789bed4f55b1b1f40206a48fdf2b31b8e59302243d4ea8e752e02a2a9b5c2cc9f90e04726b88eee8d3b224d0a79219dcda434ade5054b272486d59995bfffbc242cd6d68bfaa276d6ff942fa915ac13ec1f74de1defe9734990d8586a136c795dc100e13135de4947a9ccac13cfeccdb738b18e56ca6f092fbf02906a37d25d89e31a9ff92506dc62aa76e6a79c4316e33df6dc7a282cf5141914f7e353c3e3f162c4675ea94892bd4728ae8c4a9f1f6d7398fc310dfa825d6f4ec3bdebaf730991f7ac7e16edd71eb9d1b5a4496803ceeab0fc9e24b8f4afe218f6823a3ae308273244dce70e0ba2d16bf896e5abe32c9765a958b7f8612ed88e998fba00c99c60976ae8f5a5f33d8f68177fe1107ae2e9fd99db9a6b1ca7a4d83bd801b731992bffb0a7f5962730ac4ecee438d5b597029cd0360d3bc6e6a868cd18112f37bdad95dfc865dcd6c1a2f4fc52676c5f8bd7b6a2e6045abb947a6e5dec879771f60c1eb814b412e7e2e75fa984973e991e3adab1de9e540c73e0998eab43a9ee58a0ac3d1321119d578539578917d73a02f19448104165df8e0b16d319cb4fb63bc09bdbe6535d0ac9ca297495a6b8dbdedbac07fed4c4e73d5cbd29ce68306d1f4ce07a3655f71bc0c75a45644abd9ea2e48e4c38565a0a41804c64dee5175eea05a3ce4233a84fcb4d71decd5e07048d8b53c4a0303bbfc1291ff51da27e5071b2c943ee3fce27358a53da343f4b54ed5a623d8c04bc709165ce42bc0822971f4c5e9ddedd9d545adc8025cb3f90b841420e1b0b4b251ea6e1eb89f596e974e79cbde6489b38bb401f64da0375eff0734c6ee686155d1d0619d29965c5c579761d6fc6248fc185f8dad895aa8f3283309208e4e12ef3814ce228337e201b8b6d53788eda903fb79776beed8e6cb82b54fb1a2f6e462ced2099374189f515bf2f3622d311ee340cb21c277daefb06d1b3d0ed58acf341dda2b81bf9b8632f96a5e81d166d5131dd3b4f8035023cf56d19f5c81e0b2640568f2ba964fcb30257f33b7de1e4f0eaf534c5d45c48586b502c13eb56627e959a5d76deb1eec9f99b369c636f8fd0f9c8178bf8b9978cb61c3343e8b07df6805b8197e598d019b085a36b439fd4c82edec81d9868e56ab433c6890c184d10d29c1ee3a77baf3c9718b3f8a8a3678516718d17a63fe88cde7642f46fb550784bedb751d35dc26f8a4d070b3300e76808c72ab31be27101c7d3531e631ad0ed35ff03632a1b9f0cad48860be72305f9272278c09d4bb07f57b922c021854f879c0b281826d71cc26c34a3906352d4c8610a73bc1ba20bd790ce604c13ec7002f182ea9833c66324b4068170b17896e842898843e7dd8f9ca68287280459ee09a08ab73729c22fe308c5b237375f09b14d1ba46412b42e874fb31bca79a95a3856e71a0463392295233c7fb511b0cd3502c89948e7b91089fb60459a3b9da084b1e5af352909748d1465e31005583c86988f98d9deed577396cfb243d162bf24052c0c4e5f46b2b181ee29a2ba85c2909e1a23e6d421831dcc0d354792cc522abaf673a02975c4e14cbbbb3b1c8549ed402affc7c7339a51602db8e03f4d78912df7361d15e6d5d70b59ea0d3bf9f357bb5bd2f892aa36378b02fe176b65c785ab2e5d035ef8b3b34f844a0a3d7cb05518eb28dcae0a178418eeb862de9d621419fe2e2742bff91eefcff9cd131e2929e6ee51d62ad9ef56227359a838419b80250ae17c26f2192c18400568e48214843ab6adb5f7d9bd77dda5f99d7f2dad662cb3a3fdff51fde51867f3f0c47370192fdc021a0dfbc345044249e849fd21deb879cabaf271feacded5d413bc3c9393cc4d653bb80b24e1ec864877d82bc4a19f6c0a69fd6c06ba89a7cb68d78fd85c8dcdd3f7e2395eace2458c52ad081cbe8b82ef43859d9862ac91f95c31d1e371802cfa69c63b5c679456732858b5c63d73c9972215637477ecaf652ebe017148b3898b23b932ab392b7fa88cd738758c7c97d039a72b5e59594cf6588ebe233a74c0088ed452530ce02aeb2afca7ec29defbdf9a2ce9084852ad9764525fa3a01677ef5e072f007fda3044ed74c8f1a2f5a9cb0227d090d2b4d423e552c2df9f79b12bcbb5ccd6e802d40c264adbf47dbf9a4067ab98f3f14a21780acafbfdd00ac92f53f51476fb5431a757f98dc061fa88e081865f0660ae356f94f6c3f73a1ee54750f5894a12e9dc2e30e721c15ef72f2745e4a21ee25858fb23a5054c8c813af3bf28ad5435116d476a0a60100cfe1e6faeeb570258e68238474524ef2f384314bde6c7ce894a36322e92313e4d83a6d9ae7e9a863f255b817b7f3ec015023fd776513c474cdc08a41248e818b5c6c0daeb74d4eb8ed69a444ea06f0e6fdf9a7b6b13b9ffe62feec6605d1c2e6e469d2e2d5a849283c4b93faeb0b227628c6d372f1373e63cdf6bac7b8621f454e8d71b9e06a8996feeb26bd66a6578388f8913b21e9b88eb2d549ed695cda7ca53aee16b4646aa077617452cf7a0156338f653fabc7dca9f1f83b8a27dc5fecaae424d63201dfc6826d4f9e0b7fc1bc93ba040f763346295ea7a724bc31835ff0c80bedee106ac51ed55cca88be9804df987105f2c2fcb1427c933351aeb57ffdf71805108abf7229b105cb63415552adbfbd549868932fa56121f04d226b68906506de9568aa296845aae9f25b7a31e3c4cde7aa2af7878d0a53042c3ca8fbe56bfc09b6ebddc61f537421ed16f2852fc129cf00b75b187bf604679547df1842f91a20bad952370fcc0a77b71cadc7ebbeeef95d7e34ce0c0dbe60aa6298d4d0e87639f45f0ce8d0196b21d61daad80e1fd0489d01af9151acd5c5d6f071147f9818f2281c48fcf04b338e509108bcbd472a1a2613379c6458990dbd29fe684daca2ec9cbebfce77368f6110aaf932c67a35e2d42ee04078fc19178322222db42947822eba37be41435baea52e05c59a70de3b07cfcbcca81910b90ada5a0fa11885e0c8c325813ed4d98e1f4b542b934c7c370b684f3d934aaff6757ec83f974e406ed698ca86762f327e0b275931f1196cc08ed8fccb48780eb8411225b9c22c0419cb6503980e3ad491f8b76b5d4f7e299f201fb8195d531515ecae72cf484ad888b94e173ed741f8fc824e0e4370fd4fdce15c23a6490e9799b4588677d39ec49fe9016dd1280b847469c205b81a20a0d02c8ae23dee8c31437af03f17c0e4a1b2059b18b46c95b34b28d9a59ef3cadeab3f7f9a3b60b561b8822bc85c0a05c1cebd741950c10c63d5f1d6ea267921c7b2271a5b1893e23b612600fab928d82344ae709d9c54aef679e6f0ef44624a0f66be8fe0af80c8923fcd8637deb72f502ba12aabfa493b35990b70b4058ded334828b6c22dc9d3deb994f2e43594d0fd1294a86c4466f252786c3011a8cd9111939c300291daacf22645f834105bdfab20ac003b3c4c450fc33a57b28a42695fcac798064e6e3b0f284c6a6a824d8bbf08cca21e471f7a2eb7d73e9595c7654e275204180149713aa5c045e9f17be981098cf5102f348fce767ab57a96f881c8b3917dc5440c9e3db5e5f8a55611eed6974addf6282b07894f5e3c7bedc0dd6abb62ef1f4605da882d2f19addddc3a85e3a24632580513d3d19a3fd6dbb28cd49d847e1220d5d949b33e4b115186ea428ac8af0af7c96cd66f9f679edc690fa5a83a094c518ccbc615abbd659f7c026f811e5257ea2b625b99694b58117d821ec6bd7da6b429846926c58cf39aed08dd11261cf2c90ca54285d5fadc8ba4de2de07cf623da97e60b92479115a9c7fe25d86d1748594e3b91ba8b7b93a1d60c61f71c4f1b64e6dc9721d0799379a98c13dfa1dd859e682fc298cb177d48df2272636e9beb36380ac024ed496d9d46289d3aca81538f13ed9abaad1655fef6b2349888d99e87ac22073cecb1c639b84f2118a8b87f1d9742e3bc215a1696d553a18e2d601bbfcfdb30f9221ae74ace4e87fa669982b6ec8fa9807ec6a4180c587eb953c85dd14d796ab030c897044e860eacda24d7f6fb8679d91cacdc0b3c4b6c97173ea46b7ce725dc562050374b0c6b98edc6040d5029c3524a836789de31666a6340a9b2af8fdb8b40da6c3b9cae5513dee37bfd9ea8f7ddc87782fe4dc461b5c5fc2ebf2da77c64033ac9773656151325fd39c9c2de97e83595aa70817dc20e36db784efaca0fcbbd07babbd7487c436fb41580e2c60389f1aba41118623adae37118c431f9c916c97540fadbd6dd889c8fb5fc10bfe336bf17b6296383936b818dc706df09abbfbd84b65b42b22d860758ebe4eb889429bf7bf4fbd99eabacf1d24bd12cd142ee56aec5c26c9a4d624d8503fb15cda8db204bc6faf6779ac4db305d4dcca14118055c7f11226e80748beffca0b8ba8eaf0c30888374591c4d3a1f04ec3ebe0e75649762dd59c9261c5dc1b8c4e94f7c76c4df69e84870404e2e3c0d58270190d58b3596aa3fbcd86fec168bd4e3b1659d8c477d19c0eea06f298574afaaad0d84adde1e61660dc69ec77096cbc19c40b2c347238df8b43a9d23c3a8e0cf07728236cbd1b19bf73c748afe0885cfe74d2eeb69180fd29d1fcd973b5847e82e739bd1cbe1fc3d13dc59f369adebac220a84d4f5b8dc63b55a9b40b8f7ab76e572056a2fc53df2002b29256c53bde5cc073968334cbf1959449c854c1f4ec1df17596aa60f040d9733e8bf3355ddc29d3c1c07cf18d9a324e9735e31d9be8ad1cb2ffdfda93a7d32c77d8f2c787aa0e56f7d22212aa0d4a1cc8c66e67af72af6aaa3c683bb36352cde2b62c0f415035c2a2669d4bc0921d2d0fef65d6e3d45563f9145042f704bb29badae31ecd58439a614a3b2cf02257d8d4551b79e1f6b0f51171b8d1f3b5684a255d21f920850194efcf5f938c5faf63169668424e8d7e15e70fcc28495db9cc51e631229b22461e56fbde20c08d943b79ff1768c6ab3ef195560085359fc0db3abc00f3b630e6709487067db228cabceba07c18a5470d37772683e99f1f296c857d5f72218baf3521f7e13a4bc51c287c9f9bcaac64d86ee48ae2ecccbb90c8ec6d903e905ccb7cc83ae14c7b589897fe96d8f9abda6585195b8b68621f7cc5b9cb66848aceffc795c74b89a00e8deb52e066122c62117e60a7518206e7809725f4989bc10af06e710fb62d4fed8ddddfd6385c0cba6aafd063614365a48541f860738287e2fe80cb01249d3a5de9418ed2c1a8e802b91ef9722f068ca3f03fb45fabb399eae7715657e324e6a59daf104a991890b0bf13a5b342161b1ec72ec16c7fbf37629eae594c07bb426916327a7d689f3076aa286397766af37cc307ade4e8b592a81008ae95bde977e1bc305cb6ee20a1fcdfcfd3f9071eaca0c89ed25f971c2dd734d024322b0b2b1a5fb0227284b7d39ccfb1aec548eb51e64363c7cebcea50451cb8d5bdd756955106e980708dfaa80f0d823ddd9b5b13d0a3db887b0f8c2cd889d867c5effc21c042f8fb057b1968e8cb7b4f00b3e842230f76c24a13ca52f10394bce0c56703ce49a9d07e6a967182acc299eeffd3f77ff47b8c178f4ffa86a101a26f26ea3d461e55ccf5508ac7359d18abf23f6191d7af22a7a25582d76596318ca1d1d15e42f2b4c53d02f20f94a2c886e83e555c41a86c22b48359c6662b1983cd9d417ed78d8c18a09981d420ad35d8d494f67249e544410d903b1a94e5e0f305ca8d7f0a514142597d847526250c3eda9fda2f0e9c6b662a4a6a597d1c7f76b53b9fd50c500041049aa0a6e08c7e2c39736e1946643026e620b65468b520e8eed6d94188d839ad6d5b89a3703d2192b40169fbc06201700c55131d6dfc276baf6c7f13704f9aad5f8bc0b8d54116bcf915a696f49d403fe2e39bd2e0b4dd17db33a81771cfdb1cb9996acf917c61b3a9edf295595289d633a40b850effd9244570d392cc9d2c51be17f43e7e630820d884fa2791310dfe4ad428ca623cdd4aaa874645571af48deafe08842179817803ac409c5092ec8727544069d29a77808c33431d836019e6a53a525af22c73da86beb19162399eb3e3d7f8b85e4136a4d444a4de918025467453b0294eb9595c65f4832fc3b4f40da1b378b2cccde210633e6a0bbff245c0fe8d1687fb8ac9515b7727ddc262cef987fed667ab88c43ca7295899e1fa2708516cd426d297becd00f8324d1fac61392832ea71181eadce04af812df7011a206c1bb923791bff5923b515417f1a017d30edaf0ddf568797c11f612b403aa01e6597ca01372edee0d470a277679d9326e12fd5b5d7ccd0600fc68597071b702711a36f2ffd19d2635488194367ba0e046ee521aa582c1814b4678e86ea716851f7b0844a7c9aa2895b75fef12747d9e0e8b275227f680fd172075060aa2054e82fce649a6cfbd34bc29886e9bc123ae191185b9752ca23ce15d1e55d8bb2881ff5c80926251cdad6e98e5c7ef2aeaa2b0e995dfb8210df38e9959bf73c7e5885fa2daf72b221f4ea949a35b87d879acfddadd956f9fe61a72d1de57a10784c10f12e8e68052f2ee0afdfb10bfd65f48d482860484d67e17e24b875f5fea553649161c644b4f932e80d5a08bdbec6c171455835b29bc8fa4744126fe21aab71e8d926cbadf5237e4f550c87b6a163e9375b6a7864760eaa1c7f95e3d75088550a38d04492b748046f660c21ea62429b9b6628583755f3dbbdead0d1fd6372a023dc381a6eba29f9d1730315a14a70c8e46c5256216783a4c91d26b5b93ce2f61c051e61063371ac24fbe0112bdfeb93183178df2ad5f4f1902f83bf94211c612b2b2d361251bf26322de5af9326830641944de1154e001a7557939c8bff0fbb879496774bafe9cc8db07ec25b12cad605c1cbd7963e74429c45fc98af1d06a8845548873dc458e14b7702dd64837c1ea4f00362f8a794613114b695db3f6d2de0932956d3f361cd02c8c61c7f6b615e591f5c7a442bdc0cbda32101ae84a793d0ea95ad82af165b8b53c4bb23bb1b4daf2c6052dc87e5aa6298508693b7cd54340e671b0387d54fb84d643472ad4cc3484e746d2ea2fb74ade5df0702a6ddf301391f5c5afa3b26e8318c3d795e17323fa4a17f87283a6ecd94c4627d91dded28c33c78d78e04942bd0a32278f50f203370afdac453420002d1fe376e519665eafec8a097a31986aad9e00bb6133af10e5fc053ceb922b1464db2d96d529c041691a1463624c97d8eb2a67220f66b6534528bb5161cacfa58d648e2ccbd2cdac51810a13e668f49b21a85dcb1c9413a4fec5cf6fdd9ed7515f370043cf82e2322bcaaa9ea0ea3e30572cfb52643fdda25e6eef0a70a2d79de8aa225ebf1e23959d345040014de903d51609cf976d8fb7af76e71f3c41aa7f550c054788bbc19286e8c77e0650f74fbfad810e9925b57295f1b3229c05332fe0081f0474b5f774ea27eec0c4027ea15ecce3b6f7740c1f2963bbbb9d6f2307c64e6e48f75b1bf1231152e062c3633c17d05913111906acebbafc71ee7acfbbabb494d622aa833cb27221ff52858b7e5ec87d1a2a5cf92c71547ccce687e95464dabbec16d699d966a3ec7a2eba449dcab50878a4ad4657106db80de6d461a5757c68a90a897f15164cf19feedf70c2a4ce5ad98c454c8d36fa3d89d1b6d73c5a7d5b4a4fe54cccef4e1360fda1690096d798991ab4aeb744625b35428ea52a3a60cc32b9dcb8199c371a44d39c2687c647c37f042b129a9c5953782654a87d68e0b90202dcd07ab42e15127ae02c8f08f8fe7ab4ad5333c1c4a3f1d587d54ad84ce27f0ef550f8d2831bdd092da466354cc3a142cbd7037328e0e780bacaa16717217f2a1505f1c60365e9bdfc59f346a43577913c2e9832890dbf126aa560947088db24affcefe6d4bb5798ca6d0420f5f75548038dd47d3a36564a3371a8059b4a4dff7225e40223d4b2c265a89530861b44297770b03a2b7472d34880af319b2d48a8bccd9a43757011bbe6a0a6879068c407fa34d13f069023a03ceb49c13ff697f44a763adc8a35d7c079b58bd901dbe7ecf1cebf15302aeb41d61e94e8b6898042aa5b97a254309b169bf062a561ad8ed4b546bd7727cd83cee0d280bf2b1d0ce6def9eb8149428f3472025c6ff57745c24a2a88185a6051f0d0f5a789fefe6c527605eaf80ef248dd83e00d6057f755006f4975fe591769803ada94fb4d229a2591df33252dbcd1fe8109df82da125b4f7c456ef3d67a88d468d7221f8d685bdde3c75dbc7329f3600175dac3a571dc5a826c8a694e02dd11d7bafa62aa8f721497f9db0f51f2dfe66007a35d07a6942145af8fe35a37864fb6ba86ea32dff87718d9fd259c97d5902b93fb91ed49a8a0f3eb1fefc837ccfacd6f22ef944e617510ccf27da263f025c3cbddd5d41f6995776aff8f8c808a82518c831fc4f9c3d8ab15e5bb31bad183dc943f8b031eb77c143772926ced6800d573008825fb3bb328b1123c6e0f0ba5de9fa3ece25603b9b1625c1f9eb5c77e9648aa48d5cbe4de09ffcef111bf2754dc051583ae6b094e21f3f994a45b21fb93a30fc62668d30da22c5831a1cf3ee38105b0e28ba43f4ce9933d3fa7d1cd1b76c5750aec614a2d2246c27395e4e6cb8df665e61e32a53a5cfbe5d69e62d43016c2508766ea5f3b8b5565c093c479809e837795f5e402bd83ad8891f4bd23d78806ebf27da9d9aa2ef958a25d0286437d2e3957558bf2f1962bc788552df1cdc8a8b9b21e23e3d2c8dcef3e825cd73ce2944e8007cfe72761918b6d77f2baaba2d21a9736cc2dfe87d07e7e0c3692fab4fbe7cae26d3872478e2812584a923661a996f3dfcc380a69a88acef9871e6663150511199f8a867acea95ad31b31884fd8a9e625a17f4f47e5a2136ba258a39cc41a1e1c8ff0dfd83453e14a9c9e92e8d7ddca248b429669df182023d8809afb7a978df5f897588fdc6ad8a32725ef09fe411330df3d351ce4f28a3e5bd8ac4411a6fb2debfba8441516b7d8ea14cf43913850519e02d927c3ab26a5d3c07a5fd7235694281989ad1a94b4710c93ae0bee46df185714d79fa2307b1c0ce94e113bf07d0cd1dddf9cb500ed60eb676c8f1d2c4ab1741f951a18a7856fc95d03d6b85750d36c400206dc36f719f9267e9c47c52776b2323f99616994aad190fb52a09ec709f3897a9f12f9a948950b4225b21eba06c7b132ef29173dc0672843b3b36fa66313e9fb10167950d23025cb87515e19f61579248a704a0133fd68d87e92d273c91b61a97e84fb6d386478d2b87dc14ee1d4fa2d13be9c0412394911b195fb10752c1d845c9fe20422dfbeec3e73e0ce930e2e24f0efffe1874541e9ffa16af23e6dcd76bee05974dde9163466ad5df40a24a503fd073111e978073e4b4ec4678b4a5f7376282ee52f09e3d1b188ac2e10fa0578973909a6adb586905006a6d3ee3d9c8c218d44cfc16169d49296d7028c054f356eef7c0f6ffe465b2152f39be75d10428d78ffd5c3190267503c0fb9fb975b0177d6a54b80fb961083e319f6629a32552bbdd2e2970b65228cdad3332de328e3cd421e278fdac59259ffb3ba4bbfcb4a9e6ac05562dd884efa848386ca0f26008e6f5479c5644323bf4741afb18317e0bebf0ede14b6c76071c7d636cdbe9f0887f2dc76253d0c2ef9af2cad1a8a1b5170799f6b846c3ba4671d13ae97ccb6b98e4a0db10d3f7007133a4ed21c2d2a72fba125442d2b21b2cdb833242d832e4b7658bbcb9efbfed07ea85f3579586215736d63bd8bfebe1a118bbbc661b64e89f71b9b0cffcffbd8fe9c1627c7cc660ad58f3d4683f6cf1055b34aeb3531c88209973ca77c5d8dc9eac9ae01d811843cebfbca9a43223db126f001daee6d57e9138d4e11b79e7aa1ec1a080b0b94b42e43f995397b4fa60fd1fbde207202cb38f9113a019092d45e47d741258ebb8b9fa1e6fa4c86f26576692c240171180cf07de55208588a9b861fa1bf8d7e1a3ab15aa8daba73b2dca8af2b6307af7cc8a6406aa6c44728dda3ca408d3ba40c19e62fdf36415dad53dff764676cd4c4ed59180eaccfad941c4eeb4c7d82d7cc42f1c5db7739c9c8aa0b02f8a8436ed5053a5d78363a2dc077223f4ac008670802e801301c007be874b3088bac7be86fec122a87fb14bd6e04627fc1717e3c2e1ef2e1bfd71d0f907a354d8334ad75650693737775ab1c7bb93ed28299c6ef5d50a0b1a3afdf7dda41a93b6ee76c35b765ad746fecc1b40bfdac9b61152e01a36c8881411702156409ad2713bd2a3f145a40c5745ce6b32f51d2f7b376ece79cc6dc548e38a8fad9e1c72f6f8bdf64a087aa0032fd0baba94b32e04ce3352babdc76f3ad8790c7a15c976a1aa0e078eb987f98b40251885819970ce26b6afda715723b7d7d1ab9414484b44a357e933f59c440df66b74278151bca03eac04cf5736213493f7b571cf72872ff0d22a0f37db59162302449a57cf7e41d5f3fc4e1d0c6b6341d6e2f472ff7c978ae321ef012981cad5c39f7fc65436deb6ef371ed7b0a18f64bc2930b5700325da1aa0e2643b7d5a61430f28f5d1687aa377dba51910f1a1befae869db9f7f0e8cae84474e02c899b7b78b7e622c6220050b0bbad557690888a8b1190dc89eddb2872dc3e6eb4cacc8ed732b90a972d72dee984ade12fde51be739e49937fea176b81539d98f67fc0114818d2faef1a9389b2b272665de34052df7f779c09d85eeb02c8bdb49f3d9ab5525ad28d24608ce84210e605b0004ec47896b2f97b2d7b721a4f3606fc0a002b8954447c72f15d26051c9e3d90cdf1c456bbf2aab138ad0442a104637585cc80d175cabd8d1c4a2802444894f6fc5d95a937c27b8c7e2fd58446901e9fb1202e3b544be082454103dc1febea37c4a7a06313f1a323d327dc8fd1039269d6837ca6e8f3008f5d01de1852c45437989062efad6c44415136a897eccec3b57fe27dc2afe3b01a55dc97769798ae7c9d1c1c0e9f22a9024c5b1300dd43050264833b7a3b5d5bd7b8225d222e25dae516445534d8be3258bdf959cdfc5d6b7d86ca39beb5beaf68881a92836a574f661653c4fb76ed20c398736873daf474f28b4125301e5c9f848b1c204b06e97181a42d2a8e83ee1bb922ccee9e16d8240628959ccec1ab738cf199c4f2a4d89befc92ad648d6c2046fbd2aae75f2ee66d17982c88619354f10a1e994dc962fd52ada123dea3962391eb49e9b96151717e65de5a7dc2e8af65465aae9e1d39f75dc4198f0d957c1128f92fc79a154b1528e4b60c1024460659462aa6756111bec3ede84deca15b54c366f0ad03db4191ac333221cac39fdb178122e718ecd12b6e5962099f67433165370e3011422bc16b35d50cf452698b10194a94d7703497d5847377b82024619d9dce7940d492de3e05e64d22481b1e3456f69f2e603de6dd47bb8e8be79d061f90701786bb9251f86bcd9ae04bd1d8153d7737c40a77a09055a280bb08b7bff33eb11f2aa8a2b4d523cccc3050dc3713d9e3fea375a3d9194d0d2e123cf67892ebaa942245c224b46f13a1bcd370ec23cef3d462c694b4947aea2fec627a2f86dfbd58d29fb1b424e1b198306f41c6e4907ccd4ba5fbea5f101a3d0ca1cd421b3ddb848ca3238b120ca2542b300618cbe9d3a0b66b34ed13256216e2919a6ab69cb41eb1f4a55aa0e74123ccba3437696a86d96b16dcb77f672efe70d1419893b7d5387478e22c6315bb0b89fd235c008b210d7da892672ebcf2c5e6d62d322b345cfb086da4001b03ab2c541b3fcb510988f76c5850b17042a43d43331df9984d1370ac0dbefefaa600f49ba18271d9fb56fcb5e16598b85f0c0bea5d0c3048e26d6a7ebaf234d3183bcd20166a9b5e186325c46228821f2288f6a9346a0574d9c38e804f8a5659cc685a0825b61dae972e47a7f499d0bfe854799a9e14d4a59bf4472f7288c61dca0accadb4161880acb5b18f302dddff927d9b3a95aaf8e40acb4e90886207f97747e21bdc8db746b351c10fcf1a2400e2d66935ab248e14b76e90404fe760f1d55b9762028d1e07b7f7f4753362e9dd4f2fe7559b274f0edb93faf311419d8ea2f1ce467bdbfdcc6089d08dfaf4740d999def1e952d9353b0b7ee9fb6f32683388351689e54633d818d09dbf30e8535bfe3d2bda7de442ed87f5432bf296da9dbf2fcfaaba0401386f60fd0e6e7efe7c3eaf67280a96d953d663c3711479883e250612e874b36d3e146d0b4476dc5d77ba7ad3e3f36f8bd76206b2f63f5f26296a9867f0b92bd49ef600f26865a1cfa954f3d817fe79f26df3516793506b34f5c04c263681450af31fa4a8dd381128104c823bfad54ad2ca73fad9548939a6f2653584c4ced22b1d20afa57ca1e0a30f503c9e55f3488f2de03c9b50a3c46e776ba6695da1041d3fad59630eda10f4865eedce9c8b9e6f841b0242dc136dba4ba99b5bc0b9056a67fa68ed1cd6f8bfb9d416101ffaa6b145afdcf33741f39442e82d150683515228ccb93fea28e3db0256de5ddbbf83a65ac5403830c8eab985fbf82651f85990100f278bead9c352972850bcb9649af0f25516fc492604758c8b25646f3c0054f1633edcff79c3f9a0b18805860552a895d00a218cb71921bb412e62234528173655f38d39b988bcafd033432ab55bc126e7e33dd71b327cd0abc5faf2583bf65af55f75827f9059989b4cd36b3724cf1509460bb12ddc9e86611561c4610661a57adc5ded7eb15c517b2acfa054c6a5cdbe0b7b64935760fbc9133348f5ba84f0d7ca729a61a1e6bcf3207534d5d52b2aec4e826ff37146e4d73e2ac73d67c270e6ec852a3dff47537e22de8a5c04a508e7cbb2a1788ef459eacb5c10344d5396fdb4a6e4df120fc7f7d180e68f7d2b9467f74c93f2f4f324276f6b8977759305df8b522f31d3f5b429a74f5404a57b441e4425c56476497a387899700f71a59a4166e9e34add45ec88eaccfc0b8b99ab379130f9ea060ce1be852f257a4cc8673b1ec9f097ab46d0c0d863bd7cb667652f48d08e1a3ceda878f9685dfb121a17c86093e8adbba2f3ee0d1938e6ffef23eb0d843364397359dfca9b910f3f0b11404c49e761858a730988be346ce3d0c7ec0e4b084a0a4ef70f7dc30393af225e5021299ebc3d06a34954da0018de013ad055bc19b92327a025aed18cd285f8a23da96d9e192166ba0e1dcab00e9d276cd89e5cd59c9afe82aef1c9d4a4482b72a52f0563f027f3671331e98e4f5094b760ca95affbcd8030cf0e0acda2f41986092cad4f019eb7e4cd1683037a2c3d44a5b855d8961959e642a8a137e1fadd4bcfe0b37a9fc8b1e6931bb8d5b093eac7176718cdc75d13f99b492cb306e074a136eaf7a65309a898bce7c5e18a765e70fc05d4571816aec1c9187fa55165b12fe3755e020471272b0e745a51b3e5ccc26983f03d23cc28e5895555956ebb9e17e286d362be062683efc08162bc9fa4b3d19856766563cd9bd1ad319da12e7fb286ee4271830abf9fd03b5154cefe38032add46c5a9412e3defd2d19440a83dc059931704e27865c6705550c8ccfd7ec8f16976a1ed4dcb29cf140219a1b9346945ba4e32e745ead4dfdd0d3c4ba0e36d8e85275a585b0c01e6c1585b9808789fd271a4e60c9fa723d00960df67e3f472535db7ec4d3878db3163dfd85e468d6e5b5f50652a221f55dd9032915dd35f491c1522153062839eb9275dc53bb57dd4de95e5921177363d2e9fb37f9add0474423f2436a21f56a308da58a3848f0ff87f5e8a3a8b0ebc0fc1c79ec73ed472edb759dd9efece40cca7086c2e25ad954dd7cf661e1a4f4e370340d3b8d73db44aa787ba7eb15fa19d06bf864af6e76923453e35cf660f2fe3319f4958d6df9f405280e13630495e794a73757f289f83ff086c1890d2fc5a807b4c0cd06347044668510a8302a2cf8704e9a3dc41a98ec1cc21dc6319319da702a1c27906b21c874d433c0dba94fa45294635e171fd1c51591499a6f90bbb38a5ae1d58951d1ddc09f57903b31747d797fb6376393bb834e5ef302275dd40eedcf9b4ef69a23cd92e26629775ba65961a217912287d68d049a4390a9383fb2245a531f75a9169af551b8d5c75d4e8b6ab43eb85aff4c759b4ec002c29d011a87a2f98db1f43c306f3c9f24385676d60622803ad246fa3e79f8aaac721f26861ac292e9a98357a4c3e90cd2b2b5d12f8b8c4a7e56d3246f1795f614900348a39011169859f9235b4f240cb63a6b23194dabec45366daa58636d1c545995e4b8776d17ca656fcb39a72c2a6005b18001496d9215901f0be4a554d991f31af436bb6ea065b4d4973f27be1f6aae7cb7915bea902174db9a0175d1234018d92063b9d45b080a9e8569f6acab1c2ef5f418978bd524f11f78b9d0f753a5b3f7a6d4aa5f0e05271a3e69e1f7a55a6972b9ec891b1e4b6007b5848deced409d24063fc493a8aa2668d0ea39154a5a11ee7550713bf8926ef20ac92f20553241344b46b0633ff19c9fce022bb604ada5e2bc881bb0db7ef9889b4f48aa4147160c3133aef69aafeb94e58ea3a2a12261884ed255ed526cc78c266121b7813ba9faf6021b270500785c75285ba3f06fa0ece724d42fc08c9f4f84a734b65c6fe4dacb78b4a15a298649e43cfb4a1f24646bc409187040960f98f856c78cb5f20e9ec9a9d8ed9d7dfee9033187a0facdc818e628bd27f765ed43141cc40d4297c0e9a19b39afe3a538df4b9958babc2bd52fcf022b66fa525101d0ff5f90046775bce5806dd97376eb16255aecff2f484de213b4f03edcd5387ca8302d47f87aa7bfd543e297e06c34109cca03e94ba5db72404e9305af43d78c60af1b606fb5e6d610298526a71cbb45dc3bec7bb58163f7d9001bb1951de3ebf88e725faa057d684c589d229b880f58b0b96709a2b34d3459e0b8e1a74b68e17fd593f2079d20132db153a679f4f28c07d7331cfbea5e2dca6d6161b47ed0da86e039908a203043614ed7048338dd1d396646b7da8fc3044f40d8c95c46a88b3e0ae11514da66cda166cc44c2f37606ed5ea220c6bf1fdba9446cd8a11994674aaf8a7f8198009985cbd4b5be704e0a815fe774aee5a6792a8cccacfc42ddb677cb7c2f222c96813967df4ecca1f3ffe29ef32c9bdd7550738a883ac5df7de1f2bb26598ebdc8fe6e9b4eea770897dbcaec186c3c4051ccc47146198df32af09308a0d83b80645d1672c13db839deb6b5b592daaa8447acdcdb946b3ed62552c2c08ab541f85a20d022e05bfeeacfa696c48feae31f741cc12a3d5b53892ff7dddcf1657f98631c9502217c279cbf0495b5297b599f719fa1de89e89deef848980dfc09695c47977dabcad934ab47d2b9fafb8fb538af897a9bbfa2870f0f8b4ba8dff66b93472dffffd4694e10d57a8566d10e9f0ca24c748b7d8233ab6a75ab9270782ebc5745983f44b097450a6e598e27ca3793574391fd37312092d573256d8e77da28887fe08168b1066b1bc3f3e85df25b1a36bfda7d5983e66caebb9ee9ad06c21b8e5533d0e6dec5dc8b10e11137ff5c5609cd7ce8a3c195c3bd48f6ac8dc0091f905e6293140f952898289b9c263a487b50a6eb3b1a6108aef98b54d5acf9681c1cd6170f6bb19b544d309966a9cd846b684a467718e64028ab843c8bf0809bd9b5ffc6be755ce497463888dcc1cab8fcb39b6315c44ceada81411535341039daabbfdead03853c2c83c51b40fc9191be113d007e425f5c5d1087751d30f0347a53821120751867abd9fbc8c1e179cf1d4db5af6c943eed874a23dfc2b466834f89effdf8d25ea362e11c236c873f929098459c0fa4fd7ea3f6b0c699edafc8a52a8f953208ebbfc75103980d25a179e1060c967ac274ee17bc926e609d5789c1554944d36eb75400385db44407c537db19ac4ee88fcae99f7fc3f52a4d8325058a0df87b9b4d39ab2fbba87606db6203b28771585bd65b2cb340832d23753e8ea20e030e4c8f8dfda82aa5dd2e7bd031ddac8a57f7fa9b0937f383bb2949255f1dcee6b3ae8c3f98f598bec0d30514c968ac4ee59bf73f79571da4442cf1ddc42175f7a33a5b414d73309f5511e3966f21eba30bb164ee00fd2c5989ea1161c613450cc72b65f2257bdb31549a22422ee4c3d569a432f9ef7bb3a0e7a21f3eb2127b2da3dfa64b44b70fce8894358432b2448919212c6809bb53e6df184dee68a45143077e82f63fd0175cfcc0632e60ae41f3eb54e495bfd9ab98445fe85b03058f79f345910c734bdbd9a3f215b036a324a71af9599bea64072a13f8493d595df3b5be86baba2bd1573cf3dfe1beba91071444c5a05346187dc972844c522fcd3c53f4643a3be0ef45ce3d9351a0eb8e4630d09a1d28e08abf0638a8b02236b4c922731f2b67035384b4fee76fa8344621720ec0119ca8f272e40b9bbafbc18be689bcaa5b27d8dbef04592c7d6ef8f4f8a3bc444a68df17b8924d86d5768453c5cbb5f194b7e5e16aac367928851ccb103c9c2c991bb9068b03f7c5c52c167fca6ff9b97df74f0f53c11503d32e34c6ddff41fd43cfe4e312c206a75c50d65fafec7acdffc6f9cf036bc1a4d0bd9f3e02e82a608475c86a6638c725c0454da0fa59a92288e6d3c87e7fed9950171ae1c1e2db086baa2626463c7facb6de63bb397b66b0af7d23002a01bda9bfd5f84555036749df02a469f5b5df24baeee298077682a727a7a4b3da14b98186b496bf459c3c4953b7c20595fd9564db23f2de7301c27f92f26da5bbafd277c593f48f8c3c54fde19f6362fedaf0e7a04e2ba06b1d1240339754b1d15a40d7043c59d203918bca7dbfda353b28ea2f9dfc19ebb37403e7fa60ec6d8feb6fb3d5f71f87c1f558e367ca2c37f5faa6fc8693e6ec436ed95577a73367f9136ea0abecdb9b9356e3c7135e6169dc45e9c82bf2f13588af4b35ac364836f921f98ae0d30f34f4026fed5ffb90ef8b2c78561fa8daced8e5fda017979b975c7921c02109bf700cb349252db6ac1b27af006894bd79a943a738c46905115bf67b7b802de947b1e29e0b405d3424f78bc07c5d4e69b82d12f86d75c4293ab0524a79e273c74f5ba653451bf39f8930c24760f9512b5542b5f0ae931f9e7bc65f00ef368d2893b526b95e01b0cb0b660e976a6223fe2acb39d5f6c37731ce47dfd10006919d03397787982fd59df86c4f014ab5e64b0abbb83dbe28b6eb643faa6641beab7c493bbfe9c0e80a2f8e694905a429a314e5e836d6e5e1a37cb48c5fa0fc07be56cb45d060461686606482cf010c354cfdab4e14a42aa0cd6249c9445adb5978aef5e497f61d254871f6a5370b923f185e02bf9bf50e4dc3a9d8ab228f453b37ad53a14ddd62f457ab4042e731cf6ac0295cd996dfaab9a6aea7ce60bc2004bf7b624ec3840bbf49b16073212739501845f32f1f8e01337e4ef5924b22e03458904ca4ccc0188ebb84f14e8950f44259a4044a9e977ed3a679764c496b208b8d26df32b69bd6cfb01aa1960145876d4032eeda6f7951e017b30bdacda0d9a60517975c568c2ab1535aa0d5ab8b475c27176ecc1b88ce8e0a55bc177321e156ffe7dc7239f837a0125a335a1eb39319ec812fe7cd108a5739829f603a23ebbb4aa3956ea8c7ed90b9084d384eaa07885d4965f4936d042046a22a35bb98b26561b3d530c981e79962e89d5278504b3383fe697dd0138c0bf3f6ba9a7450decfed9a24852b4da41dbddbc01bbb666c6906a8861fd3010fb4770aa70951321920f28b26b99cf1d1ef21a7cf3513518e5dd8a8bb0dc29922fbbf011e5b1b5383575c215f5c4edfd10c823e710104d686e14e64179f268bdcd27978ce091f6357817bd42bc2731faf66eded81ed7c7fdc6ccdf20b29f7baeb2922951e54d7b349b8569ada25f0253c961c871d615232729183a488c025c58c43eab1fa3d77fdd73eb059ba883b616debaec2c7302a967ba8770a90e85e6a17b306ddc78efa4dfa7c035c16b04f4ae26dee6b6f4aa2756a2b92971f53c1a667e7245ac305ed6d128ed227282646f2c41f50454b04145efa44205b6e64da1b115232e40d0abd3e94f36581fe43c86444fc962af4cc3a46693e70ffd735249801ee39ff0f899cb47f388de2cac5d9d8b346b05c2d23df0ff6a119d7ccdfa7046ed9f98f14a44adc39c24a01ade363004a41a259e731e339f6b9ccbb1cc3305efd46a20a074acd4d9fe304fcd137abca4bf80d366668bd6769144317260200bebdb53eba32245a4847602ec743424e168ab4e3c9bdeb46ed27463efaf6d6dcf69d5d1e24df923078819d8088ee70d95c10c78f016b983326348546b1389d94914968cdbf3c1c06ea9fd94905ab2a21603618769deaff3a54df7ce5a6151b88ecc198367b2345b5fb75699a8fd377b210879a24a13005be8532cfedb45a4f08e6928758197225de0070d1d45e3a93ae15b7d1ab15b370db682d575012cf2e986fd2b29108d54ec85734dee5a550ba8ff9545d9ee6c349a6f5c8963da1c775d0ffdd404397814c4a0375267a61d529b19fa4cd0b21d3088cdda01e213ed5f191136742ae6dabaf7dd1c73153923c343e385df9e2edfbc314e78026759ba51162fb41baf20ccfb332f76c9daaa27b09b4080ae05e359290477bbc016035e7d3cc66744f7d87ddb08651e83505839f5cf594715477bb43fdfaaa7f638abdabe81a3a2c8fa3cb4090e5eb4959d63772e460794a6358817baa5625d2679bffa9d51673e61bf16d17ee41932e6ad7ea672b790adfcf0d2d65f885717b2ca0ed5e9c17401edbf2701c6dbcf16066443270210ec6cf3ffd824a33f80801c1a0c68112e634515bd1bafff1e7413d902ee8d22825610dc1d51b0cee3343e06b06e0eaac7eb7946296c8f01656122eda8d762a29dca5b570aabfc04dae987e539325927603625466626ed509a0e847c95cdcd2243d89bbaeb6c9f1ae5fa179531865bff88b6eb67e89cbc4c1388c15be746c13215064190bfc24116d09efc607c7f66758f74a8b49e99ff743a762a2fcfb96d2ede7343d468783b94e56ddc8ba6c870fcc82e5bc3e213bc6d3463362d362d5a7ffef693fd1850d6a0502b9c4c376c76cb09181edad30a82a06f91898cee31c5e7b7857871fa79ea7e88b8164b583224996e8c472ad3743b1a41a8d120311685c068936132903335769abfac450c513d2d741a761eb9e4078775eee871595d96e2b58024993a989e6decf2f72563ab495ac024fa947997c31a95c227917cef6f329bc59ec215f6ef4ee40b86aeef2c592cb4a8e938de1962aa265344bb6ce8867388762652cd9013ca570934436701af9e6491e6d0bb3be46ffc1b36cbb4aeb6bfad273e0cbfbb19122e5edc9169777e4f046d7568671576cf5faaaf9b06f1142e8b09b1b4a89567a40fff8af9a254a1b6c6e8897587fd2d69da27f75f933a7644be36bf8c9184295500a7ac443982e12eadffa3c37324a19fc5eac25de2689119bd19524bfb5457fd872c149f1ca9e1aa0c31f198505ddbb90ad5406164d62862a665e12745f897b6be6378ddd835465478b8cd011db775310beef50da079729d42a11df90208655c6848ca331155d7ae008eb75da1873699510dec00bea0c9fc083b30ebe90fc4ddc315070bc7abaaecfef49f12ba31bde6b6269eb76a72d86a79e3b9a2fa8e3e78de8d5e13d59fbb25459b5c360400c8ae2bdc846b58152a1648b003a39aeb8d433139e3cce2d1112a29b9db7e53c4501b94b499ce4169e2df851b8d68348c290ae6afbf879426b117223f8f8526713147d7a813de3ed72ee203f33fd34bac772d618bf74ac2679dcaf93b4de4a49f082b0202b76ef75d6d2ce242fb14e8ede25671ccdf3ca01ab496818844777631a512b87b6d25719de66cef4af9d78ba9e669c51dff98d8bf68bf4cfc97a1b111b047c3e4c2f88530d66671a7d5188d73e1f6af9310de6c9c571f5f869002dff202ec64887dadbb8f99427fc2db7def69e0659a51e7b16bdbbb502600d6f866aa3e3ab70bc82f6b1df191f7a9545cb9ccd107a709d1f93b5b2fbe9591cdeb54b170fff87b647e8f59b1efab51d30a16b49be8867f2d665440abd510f38b477ce849f9e035053f004918c6bbfb14d6d3e7f90cd66a297b7f51abb651a2de98e100c88e3f3ed2dd8888d308513e9ca0cdd8f8884e3077b67b6fb5e77154b9257c84e0c3cf56efc3ed77bdf53f9eefed667660bbffc266886e655cae073ea4302f7ff0592de5973a6f7a7b52291701ff0e00729ff7895c751c6a0747506a9d2c4f94a470961a60016ea79269ecc12d268a8de68cdaa476245a31a515afd10d6c03d67437fd3bdd04a3f411b899691fd6b6f45fa1598e20ad64fc98634806d0e663c521d624358434bd102b5c42ac4aeb3f8a69b3f22716e902da199cdc7bd6c9d3132abaeb9a940ad5431f93f965d5292f8d7dc069c018e827e994554754817e0c789c5c495e9d4fa6ff135a43b3a7a51bf324467e7c71b28d1578218bd33299b01d9a74a5a1b8bbe03fb3fc2346c5b4020aef86fcf1042f0f129d2ba6b3192c5b9504d3fc66291baf8d835f1d63fd096a82e58e489b383c9a6bf3fbd274b78001744ad54ff0810c44999363060cf4e53ce8cc335f649441c29f42e7003b25c417c3a69ccec58788d61c798336982b009eaba4f423648ca4bb4fa1d56d440ff1d8e7066468362122086a2cabfe421130d3b73c61fee8e2e7f432517b8e93ccd7b1416c1b296a53424109f38fa9ed93a68004ada0830b4d320f6e172bb66a9c30f01b5721d67a9172431301584c81f81c9cab55c933bfe4c000ce507829735fa9ea38491499e584bcd6bfeb36230b48e60f2cfa333b7954f36cc870b0db6ce139b04be98ef16e2d6de93616513ad6ee15b00e42e5c7298843f8ac7b28ae4731ca85b969b8ed03541786e2e4c5858ae870b9cb037436bebd353dd4a5672db66726d5da823e355821ba6abe80837aa622879b1d48cff134977193575851046158884e156383db4c2dee0b8a59e84e04a1265b1a01fdbfb233d48b22f4fc51a9b7b1e64423641b41ff5523ee7b2b190eadc18c3d49e6538e366b290857844415aa946a0efa212f45614e3e748d3c2773cd520f37573991f82cce647416c95c2bafb575965b329009769cc19172ceb3c1a8832895c955a9429c2af2242b90aaee62b9f1bc043dca1327f8be78d0981b811f0af3b0fae8d64918dcad94db68634d5b78fa2f2e4e34b17e6dcbcdf76a3e7f047e3ee84212bc9bedf34bed3f96a790ca80d9b7caae31c41d09ab7f6e4c4b481d766a078015ec61cecd03ee0b9bb17ec71c31eed1b99d6588d3dda8929f2f291dac9fec50e038f73088638ac494930bdd4da9e94fc0f98f44a2a0f1ae56718e43de895e8cc7945c5d115e2d0960ca01d5c198044c396473b332568af0795d1691b3de762ff0c80e134eec25812875c2378d18a6b31538c0e89f7e640aac16fe408415808e052450c3b20b693151beca3686c765c7668652b32c0d304bec767a36b9110bdfd012a8c1de45d050044e91fcd5c7f5c3dd82560d57ddf32e52cbe97e057a80b6a9ea0c3bc3da3c397e3436b81f3978dfc3f6c7efc1ec262f18d2528ee5b8d4cb80e49f6442c07140e3e9224f1f518099b4fe26cea14770850c7958c3469bce38cd486a8567870f77a3dc3ba94e29ed4e53c8696c960996e526d8f2863519371a88ac66836c3cdbea5c7e6f140d03e8510e84aca2026144e93778c88a039fe54d92f0aaf4624af2fe2a1b9bd2a2d308ac90be3f53ea538366a37db68497c6613081c49a73743ce382483fc5056771b35366aa8f1f7be97d2803b34bf086386eda63f0727b38e79aa0596b91bd2d2303c8919c6eed42d499fc51f04313cb6b1f91a45da764b5eb7c8121d07444f1f7028712628fc2beff35aae4cd7ad75d46da46205f61de780de8099c2a936bfc0d0449c31c35e81b4db8ea89a412ccd3d5d5d27d8ce4b20441d4d9ab4931c12048208b43e31bd28eefae2ecb0c432ce7138aa6f205d9e6d0a6237552f36854116779fe98b8a5235077d0bae36a9b8410c0af7b6e78959a8d7f47c7ab99cbf378c6b2a068613a84dcf2a93350b3e904f47e48dcfb86c20f4ef1aab02f8ce3f74391529eed6fae62701d5d736537d6c866eece3ce3e35ae81570cc70cb8e7845a9e198a37858e4a7d08aa97c72740ce6009777c63fb0e5e66982513e8482f62ea829c0e83729b576c40df496aca5e2997e4d33922e60f50f218901515c929c7996465b75476b5f0889f2af3c7ee1774caffe1627e61363c5b45ab8136453f08e04960498d463ffafa0420d07e7f8cf3ef7f3ebc6df207fc75daffdb55a7b13bafdcbfb8a0e48b9d30eee2a1099e63d3474c83473cc68dbb3c94c58bf50c54216415830d78bba90ac8583188eb6139c43ba40dc2977c587113dcadf1dab82cdce6d15187cf281369fb7bb4fa84909a6de079ad7890e269c939acf6b212f2f4c68c799350aca6e5d8ad114f21c5abf8f3b12d526307b8a4606bde5dc1c89d3f2744747c73ea7af50c973c75ab61c3f6fed3f8188b4a44a052750c4f5b96742ac8b2715fd36529d42abde30bcbad8ecc2eba36ca0dfd3b2846c209b035b8c7a9273315cc1ac06ef87bc7527997898dc42fe6ead4eadc3f95ca37c67915a6a735db0b195b375c460625008764bd31f603f61e8bc39451d56c50695113aad5a38fc1bb0424d637098372684ac26bcc35c3907212e3275b09e15bb655ad23fcba9dacdb69d7ffb61dce6a8992c820f68f69d7be2ea35ec9e319234d1a4fd0f95043499af2c6931372004240807a54249e5002fc79610ce2eabb9ce605a49f2d9122d03c61eefff5c309d8c9dcd37b3eb692a21f3416e37ad9d536ccf0b59ead83709e26a3270e12ebafb65d092551ed28baa0dbd14a28fbbff6c55301c7e01a9bbbca75c3e3178a9dda370240c95b66b5f04768ec565be6e078f122db0a80db141bd317785b8cdf87c9e613384c2732575a2533830e6345ee4409b316eeb7e6aaddc4edd1b5aa4ed6ef8c30a44960136801f762d7d150f9b780238bfe0a2adf6e7dd7fe7f2e150e2deccf09e51ee1041f149d0ee5f3643cd1f310018147c905a5b3c3e0c5d260f44a7b5cbee6f047e6fcb56dc649e2baa673d1455ffa81e42f7f5b30557a44ef9ca0c3af95e60a7f0eb169a373ed7dbc5dd2753619808bd3f3bccafe5e008b6354132ffaa286dfdb518ee0ef21b2afb545d19a8250ba45ececba915086c760d53e12a080729e5f10daa2e1abdde813fc2afa1a796a872bd6660f743747bc61d69ff552a6203618ff9b2ec44065d3fe2ac01f5d569f5b3c49c40da21a48af1992fc8d5e3398a2c46cca99feab8f9c76ec18774ee4e9a28038647ce13386fede96aa54cd3bf3e685dd0132c2ae4293be9f96b223bab0b073649ea08eac4a33bfa5e9601608a4a37626b6ddfedaef1a78098c100c6afeec992c07bc8e25cfa7aac30d902931cbf622fd161d12905ba0b4e482f4e18fac3b63d4474c5fefb3a246af6e10a31d771954936c63607d3471c889d2d28ace8cc4a2ff274f7810ace07f785714a2267b9e3ebacf1ccf29ac009946cc751e206f695a108a73973e2144e79d9beddaef94b4244318eefd3933714bfa621afd919b29a4bca83df28b2c831e2a26ffb212962a34fc5188102522b271500cb37c3dd2c49bf933d4ada08a382f0739f65f2d0a01a687dca98e12f9231c11d076e14ec2ce815171f26e1b174701a1cc70e7511f7e818e8ee8c4dfd162a27c19f462479768cb88132aa0930da27ce80f6d476c99d63611934d10024537374da3a12a85576341fa255a1de0f76d90d487f40b65bdc2dbe9815092bbe2a41cd8070cddf91aa3c120cc683075b50bf00ff043fbf3d909bce862c6a5634f1a816342894eb8a32d44a0ec4510a48a9cb0d62586387ef5e7a920689455aeae475e32af1d7163c3f6a90692c418fd347a6aca7576e2369856dc8da3ebb307c8b8f7377df803989f195b0b2a82d3392252af304eea6eef2f053666d6c99a1252a1bb17214f390e8c77308e2b7d2f059a19b23a5c9d86818be391068b3e7508d4a6582b2a92deb05647b6d6cbefe10a527af2e7ba80bd6a3e646931500a53650e6eab6eeffb402226657d04c67f04a38ea36648f8ed4c03773b6b6b703a0fa674ff5e944c7a61285e13fc0f2ca49ed4a62c62a9b395b6dfd3d77abd428a5ad5c01b5340ca5e63ca2b022b38d324786233307f8c4ba1734a4cb3391b14a78061f7fa3f8fffeecd763ee0ba963764831a2f1dc3f7b03ea6589c9a0a04b9c2df5256c93f304bec716c997170eed9287df3dc6fa31e42480e769d015b650a41488842d6fda5ca8bbd50901225da07c2d7bcbebe088d561ff6102543db57724d37fe959e1c9bd044421c39b3e9f8bf6d81bdd1381ac3529d2010b7af307553331b81acb65c9486c6ccb509ac89fce630463262a1be38638e7080d3feb298e5950197cc042d5e5006c89e4cdd93316fe3950bc6e7296994b5ed8217a54af0f915c06c91e1774251a9120c0456454904b416195c9cd56742ad7388236f88a51f822145eb7eea9240221a08dedfa0e06ae4c7e1197de8d7f9c299122cc0f58533a72e41e57ada32bab9473db9f4e12bb28ac3aff7b209719683e80c8ff6f8db86986ff79e51fe1722083badd09f8336cfc001b4278b04c1c1f04fbcbe3bda16df19abd41ce9526ed1ef906ab7bd42ca5df2f5c748dea30610c9351bb59961afd817cfe998a5cc995349d7bd33667f463021aea7e242731196b06f5a7224c7e10b019e793565b21b0ce2624bf0e4b666e5f616893a073811ff384455eb0d9aa549a3d4654009dbcf7feb6aca74a50e0506daaef04b0857eeac2c21a62b2af05df540e15a02b472ee7c44f93a1e0929e76f30b1d3d3da86baaa77195fe2260fca96c3766a6806c056afc8e171c93d0b63cc67e85ac6343fbe0caf466ea49e9a1b7e7edbc56f06b16bbe1c7d5b2fcac1903b886a69cab15ef69b1e2ce51c29fe5d3f8f25521e58f3b19d3678d0d1b447d0e9d99fff807cc8d458e383f8f1dca56c93d902e7533937ee3c66958217c5ec32fcb12d5b29a2af1c1e39963df4dd0aa680947b764383b1c4e66e4a22f5da8b82ad71e1473e58bded686be5d5fe10cd7c8177459b671d53a70cbe25881821d4730adeb79b738fc224d2800d7a34d6c6f813e44877aea4fb09686e84e8bc82d328b5b3c873dc5ac7bedccb64299cddea45a592964c30afb319dbc566fbcaf4fe1fc54fec6e85189cd3e4de9122baa82b4efb56174dfea841308e6304807a85ca2f11e1df3f4ae4ce1dff74e2a455ef529179c7c173de7a5cc0024495c805af958e04480097f3e9f0233b4830e778a346375581fac8fc1a6b4cd476880444d0dc3285fafe023c5e28edd1c010913cb6e9272c63243d7837774e5b22d5581b21a69801ed556be35130924e6826112953dc9e27540585f90190d45d57a69b1850ced60f820fe26cdd4a22e7028d2a3b3def686488fb71a8dd1d1c5c4f20097c49c372fadc63d470c0e7094b440114479a25cd836d102d2f450eb91456d7f9793beaeab4a7ac70ff7a41257c7c15b90325a304b632f82107f797ef5eb60454290764a4f22b3357569cca1f1f9cb66c2fc05b45487d483bb3c2e38b4f589d77080be53dd4c0166007d619384dee4e53ad3b15aee1cca3386595fa6ad2dfeeb39e942296e47ac6ff62bab3c07b3e8c55e5013e56c730d7f3d9636c07d221de127209591451fb8903845b168301d16558c0cedf4ba0b1f60a7f58f7d08ef456831fa1dbca5b80b0091a630e6be6d4ee43e095fc316c9fc66e0399238513a08a2f419da00b82e01ed2662121346f4bd7d52c16ccc8907c27cc9c27d6fcac71c39d223033d4528ea15371e4f1d505a5153a190fa392157ea4aceb7cade973c83f88149f403222598431e77be11aa76eb426f465f6eccdbf30e6755c8d659124e2fd32c013214867a34f0aeb7bcfac2e55895805042b44b41954d908f405c17789bb791d9ab6b12570688cd719c618a62ea1f4e40150b53e67f61a4970e297724b9a477d020c99d2b22acb144a1bfb3f3faf893ef757a3042a6f30ef9d2a44db685c1513c337244a15a6bf860a1fa8880ab8db5a567981d87e505d30fe1a5e8fe31352c271483c602b053d4684638c93013ceb998c65f960016c6949c9c2059dc6a660e08196f01450a59f95afccb81926b1ee771a3c980d791186551da5e16099a8e6465735aa45a10f199ad592a83360b373784505113eaa5de0caa8b499ca96105d4f0de9853e70f45723f1b9226f365bb092d94e08063a9c1ddfb0a0160d107fe4695b875f9a5f0129ad9134ccc075d3d1e4fc15cb984f42205b65098d98dff8296855996339a835a5a30dbe4fd66d19d979245ab1e07244409a2f67d905dc4fa3bbd19132311caab3a0a106d8a9c72c9ec8a4221320c55e57a3134d2175db384a9a4a279e0b98c2cc33a1e33273351758c85791fbfebeec180f4f4327e916ee0d7ea2b46bfb81c401c20e7b8e1cce060855cdc5e40abd52894f008ac8c5e8997b0019e8a0c55f23fcaaf83925fa92335a89ffa74706e24c0ef00f347f5e38d4a7ed22baa477c2afa9bfd4e9aed5ad5913d29cb37f4e54930861c9041dbe81c769a10eede10da094b34c7a0613957649bbdf4c2821b86cced996d58076a9410abeb0bf07e6acbd7ef8d71ade21ffec7105d451fbeb13be37c79d4d49dfa7fd8d1e518efaa50af5e26d056196e70d54760bbc19d76fffb66e68ae40f7fdc75ae0a725604c5862696c0356ea66499c7b5ce803de1c9f384645e524d258c47cc78f1f088831ae971ae10d756f6264970e34a1b1df96188ee3ae5b8ddb63ed37dce8f0e3295359e682ec153510a18df9e0c1dde23f92aa1a77bd7aed5e7eac26d68170532e37c8535efe244439611adbc905a2af0db4b7d43212a66ed264eb96e524cc33696080f845d6deedadf57b89f44518de82e340e01c047eddc1732166e79f139085a658c9c74e88e3fedec6e36f41663bdb3d4cdb7b7b729fcd58531437b7170281d6202962b7ead316f33c6c15b927b955d19d54b677cb8d6db2542a007fb1df39e700cfe93107c7feb2fa3ea53cc2e6cfc72f5013e11f08332522e0a6e952d38fc6c656daf791566054adb5e19880e64a678fba7c22787ad9c14c6d0b2571c9bd2437cd2af614f6bfd16ed0e3675d1e8ba4d4f45ded4c1d4a5e3d912aa191d980416a29c690e3c5c997ddc3b1b064812dedd5f2ee4a7fef7239a073b2d7a1960de0ea622e7616edc3fce458c2ec8290cf38f96f56c1bb0a9f3b8c571536b8df34be25c3619a9d75fd25a28992d9f6d55f54ae1a70fe382d8c42f544eec173606855edc3213f056846b927b5fa776a06210105a83ef458d8696beafb3f14776bf7c4e4cda55ccd173a3dfbae8b2fb44b0d4eee8ee3f0cfef95b9f409016c2e8a28d5284a6bfcf678f246681f7cae44c4cb94276ec2324bd48828735a25ebcdc83dab03ab42900727a9d5fda7aff127fd858b6f725b50d5433713a116ef7d2eebc1d15b18b8d2276b6e02648acbd3852cd010a3552e37fa19319d9586e74b6306c953feba4ee3d3c4b407b2d18c99abe27df7cd118fd4cfb1697b7c99780af18e1519b76eef58c1d76ae50afe819bf84aeff587f729393ad447c530a4b851bb1890f4e55a5a0357f39afdb481204bfaa2afe1fca233a5760703951fb3cd2515e64f19ea60212776c766900c9e5d7ce00daa548c1dfcb8135502fdd08b2749066103968e557edc487481003ad091a32488be2fd2cf51895c4ef760017d06f9edad735fe6693d7be75ba22808f7fb1c5666c4ea48e81fa7099a2dcfdcaf55ad3dd113072067dd65c997682aa465a64f8498bd9c01fdd0edc55dea3b36bc42143ab1cb4f921504f83faa91668554463027fc0913a039aaeef6d26f37b1dd34672a38ecb801e64d5f615fa1b17cd1cd0e1e02de965574a4080efbb59c87354543df934981e8bb6555719819041bacdb8083fe10c4a341451b6d48ba9e2faad904f27b08f98eee414b9ebbcd2cb35a93e29359bd44c144f9a5f61d8acc4ae32f1e69a3eeae4c300d41cdb984d25a13d2106e7343ae1afd947963609704a33ecc7c1ea55a9a007ce7963354e02858cefb30ad297a2f4f59f0da306ab3996a0d7ef607aafb830c49ad53512c3bd582ab7fea709533404cbc58e95ac7344ab25cd2cc4745dfe14e6f383509912d81dc11647fac32e5173f9a2d5db1d09c3acb531eefcf9b3bd965b9d5144d1a046f7d56970819ce58c38e79ccf2a2429fecc56f855df9ee4193e6f89f81e4fb6bbcb087f8184920b3fae0bfcff55d07cc92ad84e90b9efefa5007a743d9f5397d022446c7219daf152a327d44d744074262206f73b8d963eee648f6c308c552e181996e932369bee3123168955e66de550eca01af773daacadca91436372538cfcb22174becc23e2bc72e8006901662585800b6dc32b379d52ec41398d892c4117e4758625d6f6791e432fc332954e6714991972dc8629700a5ccc48e7621a61adab7b626ed962b5350ed35b6a1f15551a991dcb62f43bebe14d923f01f0feb8e5d8e60e567dd221035239716cdac01abbbbdcb43b21f0b7f9d4c3981b4b9c2348448d7c7388f6bd005c467c319be5b09cb9b0361943ff667ae2f7e8155af7b402f7c824f188d28c9e5a2cad6211b372d3cba02b8f948efde91c1eae8bbd096ee5915b67cd51dcace1ba2ed127f7a90f04c570470eba8213215963100d48749b86e1a55f9e47872bfc2b0f86ff30fefcb301b786309d0a8d41cba66de8ae899b1a1c900f2ea8221e1114e3699f0265411a9bf61b12e65a75dc82fea98d80508d46114d726caafc2b5cf6c91518d06dddaae379d0b4c2fc656ad4c709e28bccc920b965ee3d2f41e59dda62ec759beae362a674305f8d9ce70e15a22f441f7d38728430eedb70c3a9deff578dfc51a603482546c65603273f228c4b73190532c91036cddf65593899fa2f8851ffcc4e11f606224cbb4856106af0a5669361d4e57e149a021989b4d576a9e6ef0e2641ca622aae031c3f8ebd5088116f3a86c601d47ee92093ceefd362a7dc5b81a4af1ba91e320e197c800d5a172b1c64d84fdb799082e3c927ac06e6dcbe6b328ad6b8743c851151df97670b382964e593cc187a66e3b35f945054ae52e84dcc0c51f72b2adf60c13c92ee21263470c81aaf9b214c4b22418d61e785228984af4f0ec3a51c4f9d4b1189e9451308fe0cceebe941e4dbb7598f9de9c66b35659945d1458e82a854a87d21a29f7d50939a301d19d3c9ca300eda39eefbb171dc79db56069f1095743e7dc88ca6b73339a5e02c7c8f509f1e8c845edebe7ac9edc43fa7e994d1f90267472c39451a971d88903f28b7e001c7d56b702dc488593417c73e4fb95a0aaa9489662a73084184a480646c821e6bfd4668488eec95f85a53e18d909fd8614e2e454b176f11390cda82036b4063a8c256bc0f775da7067583839537f623f93b8d3d90fd007727740e98ccb1c6380fb3e053cf9c73942f029ec62d26a1b080bcab9bea7bad005fc8f5e318a5d455e474da36acb2f0963dcdc051fbd3d3a0fab14f96a9aba2f5af6978b2d83a228d8761948e109539ec16d6cc4d3099ce72a7eb7010cebbc203cdf846bf9b1ca66ae097e3795c9fee234f1cb76eecfac074acc45ab383862f47be332e030820072b453a74cb00cea8d29798fef3ca4fa4450c0dcb943e4a7bb6b8874dabfcca07d4030320c7d68cd76da62f49a195be79bfb6e24055f26802c603eee4f291fdb90fb40cf1135218c9dd77ef51ede13fe1125355971210984115ca5df7d0882f5361f813f8b3f4cd3868217de9cccd3e903461eed3b0525148e6623c9048fe09926431228ca551bf4943dcffc5cad9de547f7bea498b2ed731525237c34096bac2383de638702e6f8a1e01c1d6bff3d6d4d2b497f417e88b81f8e4964fef75dd83e9bf782a17b8db393cc62db4cbc23bf35b9534b2b7e52a883ac159274bab367c323e4b324e57b27b7db8e76ce0353d93370bcbea935e7c42cd3d920002df3915a7ec928a66a291cbca5105707a47f1cf8ef7d485c7188c0b0ee9760e903723eda79cff902d14b0f3647d40c716bcf4d1caf2518d0755a034586de3443c2350184a0235db29190229eda526d6af368dfce609b9a79f7ab6065c7e175f14539ee6029b2c23f0364146429f5f8b8257b9a868ca9211c812b72d529ec1cc0605d6eff64f2f5c28acd1f22d08cf3f66321cdda2fcc64958ef4a424f1e929f4a16d00d8eb485ef578f9d19272deb95b5c246a92931f9590aca1e5ffeefe9500210c535c50060cfa78e9374e5467fdf39c3fc664544cecf483b33f3fdb086bd09a10399c790f10cb43607cf377fba9f495cf7f88c41a6ee7f84f4ced154d40765f03ace25b6e4ba3eca3dc44659f0ab7ad801e9b05c679a4806521a9dbab50f864d760a4be85775b0b861d758718976cc99860593b5557f6e6ec6418430ced96cbeef8e3388a5296f1510fec74e52f113fffc73c8c52ccfbc2b2ce480615ef39df24c9df1123ab0cbf2dd2f1801e74b8a01b2e916c48e1a273d22b351fc08dcb8a4492652ec81e4960f33b7bc30c7f6246c330383c2e166ca6fece461a643681d3e8c34afc82874b3e7a4c704e9c98e3a1dde94b855d74c0f2b7fc3ba7863c5f19fea6b551adde025563e3f382983e6074ae04484f5624994d31013919e75c25f100ce14c4de321b69156f48805db3e195c5e0d8803b352da9e6ae35492c617bc44f9361aede77031a21690494e79e87d1e8a50bd04ce3643aa7536a3e632184e4c240c40c7ee6c90043e7cc5d867b24147df769d09f53c0d6c577621c0468ef057156816e4ae080bd2d59324f257ac79e50623b6980484aeae66eea7d84e373e7d1ac40895f0baa7a1263e58620dbce1b0047cbbc1553bfe51d0d7c1a2a4a119b8c521fe11ce37df910db073823aced7a3e3ecec4c22412d5f76ae38a9f8ece2645136b94669f4e7e47cc41ebfc43157f6f3d4a33515d04b594e0b37b285102f2ac9bd2fcf17db36eb4c746022095fbc974c3d74e40e75e32e775e42d52e08ebd66e5cda7b731de7ca1b347b9f6927a40a9030a06df223742db9f76292e52c30596f5f035da367c196d1f63a0715aa3d02e80c6afff67f3acf98100ebfbb6bdf22a52d4314e0d56abdcf76951c32d74c052293d5fc91b5b8b6249fbd5792bee129ce396cc50d778758d4975d7624c0dfe6e33412fa33f18360b2751339c024ef4c9f7dba05fef3c307668d7e2dfec0c8cb35416320ed5a8b0fb5a6816239f8283ff8582fef9baf0a219566c47d53783cfd230a955b302623640644783789f6da119ae515423affce6cfacd54951151ce2df23f516d0199d3938e2d730817769bbfb52456c10cfb1830abef92919f72864bcf1927273f166c3e76ac10cea92fff195da57fa6a91bc2602b1a20494b68e66d2e8c18b9801bb72b8ca31bbef464d3fdc7e179a96aea8c8bfa8dbc78d809769bb346498ba4da869d64a6f0de07c5448429592ebbcec3c47379a3da965683c408676be010dcc6f8632bbff559aab0b4ebb2877d9b78b4776fd3cfa83e6f5764fc408ea5a7c5005700cd9469b1e27aecfe9199780e76c0884799fb427e57a218753ef3e85cbc22a6998aa5c2ef9a9d3a5bf7bf738f36d8c66879eb74f02a515906378c15ae6a71fc4f838f3578258d716398b7042fb8b572091b95e67b54a5d06713da442c1584e65602586b49e699e8abba008de3a4c88effa4d35dc297772962bc5ced08ae13846e1a9274e9d37344822382e71d6214d1fa6b1879879346aee2c50d3f83d617ad095960c9d7f4956a37bf41ae8de6880c1d0c7ec72b44b56a66da907c2a56cd6641abe3d05b1bf1e6b3c31d2dae2f1fe075eb47e080f93db52fce6658e2a34f4e7ca25beb77927f34d044b1f238342f8eba74a2cd85baa1c68d36a1e70fb1d5598d32ba49279f8535efb7d27974e4335e86ba6c029a266592e41ccf9ce1c83943e414e565a19cc5d1410655af1cb25e8412714b8ece3f455908927ce72e6ef50f5d54d52b686cda610718191bf7fa8adb08ebd9e40639285cc6ae11041d3d01f758f9fab95082dc457003073c09a3c24e551a2f01f12c37f21a9aead0ddc831bd12c87726878748525b04ec595f170f1b601cdf134605d73068e3fc480d4fda74771ac16346ea0798ff4378c1968c588710be5abad17900efd57ef934c518c100c1a9e25a9147c6164bcb8b2b5b628898ff0d3d5f950fbdc753ef4cce2e7ca1858fd46168d667dd3f45797c5f5393e91fff5d594a15f9c1c027e76e417c5b3f62a9ee3bfe21c8fe8411ed99595b93fbf8c2b4fbf11602b3ff1a4d4e776ad5e459a3377197365c46ab725e42a43eb3f729ac9e2e6a3e7e6f4673b11bbb62032d851d0160a88923fd829f901e2bff49979cf4f0a2482fd45aaf8bb3e5fe0e329df1f8c91f049a1be8896e4c616c387ec90e6def19220bb24a7d6899c74f87e3f5052e23e724b685a7734188c382a66b52102781acba4d5266719d61a9db95594470eb13fd096fd6274f6a2978bc574141aa5c89eb9d54fe67e4404b27b8dc37a2dba4ba22b89ce68f89b3b8ddee959d000903ca2dcfc7cf9a29b56373c11a8aa4ba190ed26906d1b6d292170c0ff2e9cd4b347d2987fadc084704b625ad69d2575b34db6fa8030bca3f6b6c077d1f9530b01346b7ca9d380511fd6d8f47ca756e7fa6fd7be8c51f7722e17310dc1ee1e7ec91920e1bc4c49b1f407daabe5234f9d4dbc2f5275bfe8834ac58fd953e0bcd525b22758f63bf94e21dc6877e2440099b7019fbca0ba0533e2ddfb78283e557589beeab79f1e16b2fbfbe5557ec251f78b529b7d341ea5c53e3cf0358f9e9c0afa1f78ececefef7b5a8e0e64a7de9b7a0565a8bceff285a530e3718794bec4bdd0565c973e6137138222fbfbec66cc15b185abc5cc33983bfef3b21a9b36e0667ad62599b5cc8b14e51958a6cccedb7e203b3ca3a72a67b5bf0dddd55d2b55967c080943968533d13235a4cd237526a86e31bd72f44f7187798de4a6fef19a9386c182d7a38df9b4eae8eb6201f7fcfc2e6425b08a143e5c22dd93c0ca1a3d441b5d2e82c5ec194fe9f52bee46ad78723cbb0e219f74198f03534a8f17f9347473c226918b7cff4c69fb80c8f788994f5f0c1f445dfd774d7f931b737f210f5a4b985f4bae5f4d0ee9be63a4d40c8e7450c19e5306c96b6ef8a7c70773fff12b38f8fd61130abe3bd47b4952eb34b670413aeda00fb9cda59d4a6b6e8f599f4fd0550143d862395cca204031dce489965924887102045b339a94025adf3611c409792a0dfd13438bb247f246473ae325458b6139ae116b96b48561a722073bc93718d666c2ecbb7f9bc1f02092b3b1a352ecd634696b81c7fa29e81372072ad9dcb7877adbe1683be991f38c4442bed8d957c33efa43aebf53a7710e954ce9fad17f5f38e803e84228350695ab871f433a30b1915ef8c47db59ea4c82e18147b9429d9f06027211a00f3879f424b06aaf0e0a5b8f70b1c352ff64af11d0503b09d95f1f6989699f059f7a825679f2e7ac0696f27b8b4c7cddc73ca6e0ed8fc828f0e6cbb5e2b712c310b32db692c16cfac3bebf1f6ea0b4f95a7b56bb457e79a103c33768d5624e7c7f5a09c44518ac4e2173e332793dfced0f0630c8d7489655cd61dc393d1bce7ef103c10c10e80d2aa71b396704f78007a4103b807b42ff3c064a4881642ee708845df927b8b1b1fd5fd4165796f88c1dc788b26ebc7863e79db113230ce30deb50fb6d7ec77b9da7f559496ba613b3f9fe5d9e2a8807ffbc1ee763519b075615068e7b91dc6d96178f493386a2630a7fc85d964410367d0104977e0f43f5d846ff7c19be804e91048d4439a8a0efea2197e9d249e2c070184b2bf4b5fbf89d085c0bed669e63d7ce36b0b778de25205b066022ac8cd614668c971712e677445dd1aa2030e47173829ab9df65a49ad42a9dffe5de54e04f89d501f15e5072a7cbca106e3fa52ea22ce24f77d2c1d684e15ecf1979e64a0ae5981f60b2e81d119b9de730b188a8d0afbf4df3d824e64bd97c803de46820c7a67eb6790e0391fb3691d8ae2817b4e9ee4c37ee8677d6582fab587d195b5ac1c9f093d52bb06500e77088b3bcd7feae3efaf8eb701ae2d8ec72188c3b3733ad565ff8c9039c3ee03dc8414e8db74d9431eaf2cc04d38af27145d43d7452cfe6c9c97da41ad30b4d8364e1cc7054415d985cc6f766219011385f8c21d901cc03b519ff25d7ddb1b08d7b6f2b9f2120322740c69b10903d11713548d45c4da3bead46656f06c5dfa88e0e8bd12a0d7921b49d81f8ff0a181bdfb384b142804714845f56dbe03da2d43c9e1c882f49855b906568b8c47319802da653dcb54fa5b4b98b4785e4aa642915c9ae47d9e503505a5fedc90456a3848901acc41e6072db915922083fba2f8c7e02472bb265704a501935776714eb0a0dc5ebde3f24094d60883c00135e9653c5106da2cb3a86c492ad7e8a8ef91db6670be5b0a973b9b4325f0be0f99a8caa56e6fab8c688833cb8b04999e94f06efc8447b6f4c901c26a90ae01a8def0b4a7f51ef95077d9a3323d82741c6c6755087e2c33dac7dea65c99978d41d12b708ffe613dd8876f8d50cd311ed8b3e4b73ca47f26056a380cdb4755ecf0d2dd0d7c0a99c7cf9f6b35eafecbfadd9857748a52641ccf2d3efa624055f8a84cade79f6744bc9e2f6fff5ce2a977233a23942235508b8f232443e80a3998c62287e071e0f24083c600866f639ab0e6f637b4c404f2c208f15fce2aa1d95e645bba8549dd399d1bdc7e8bf2bd699f20a600cd4e2ac530f9d957ad9efc92392978ea07ddc11264604eceb381c7a4a1587727577ef23e34dcaed611161ec86e62e18b772075c89e56402c21dd8007342ee3e6c2eeae327a1d7d510e8f5b268292047b885e00377ed646e6f9811ba608dcd1e0bc4db05de507a17664da284e6335313b348ea02800759ec42b6a0e21257166c6cd90e4a5fac0604cacbce3b01a81038e003599ecc6ad088a9c95af9da753d1005e4e857586c0e57e5f74ddd63901fdf9596d519100a51abca319c95af598a41e39ce79a7b742a7c1c85c6d792f3cfc2f8a7e325d3901c4331f1df9e2f4f9979e784454a49eafaa6b047c89b13c75f50372154b2ae3324991e98520b0024c914344aafa444465f9ed07f65397e5cc45c1fdc87c87f0be37c1e194603610a8329959cb867328525813949bffa38f23799b1ecec75508409cd8ee4f30ed6b9a619e96bd2947a06d93bac545e375094232324eaa6925d8891d48557482ee97d6613c8dd32eadac7631e5d61d6ab8adbb364915bee89b85ac7c85250b3336e84b5d15d3a1706ab4357689c13c3b652ae0eb84f0ed67acf1e80e8ec4c4eefec9d464f7aa15863ddfece1a67e9f75f0ff07ab60d3e391adbd02e0f2404632199fc3a3e0faaf90b25c11aef8dc99d48c55d7ed79ee8efeccd001f90a7105dd8ceea43f6853d0010ef67dda9e6cf48ae095b3a5df3ce603266151280efd93dce9a193da6a7f7f20b9866554c0548a76c9d2c5556232e509f595bdc3500dd3d6262a98c023e60b97645ed7467208a741946182868135a7a5dffacb133dc62bebbbf2ccfacb23c70a0591664bf35d7cb206e34adcd52176cecfa8e274690f1cb350d80af42222e432f66aeadcb0d10c1cd67fac0a1ca9c11fd0f698a19245d70fc241372abb8a73ec9f3a0051aaec7259cbe6f79d108534fcab677dcf6a2374e6caeb0e78196b4635aa0ec83b22f1bdda48432e841ba356aac45eca18b828ede39811d85ccd5e7376b164266202c09601b6e872e43df9267a2aa960a826f51ee8cd0e10b56e90bf97f9bccfb647da3ebc541719f73b2f03e220dc01a32d154cd2f5cbd3dafe42013c713f881e376a88c49a241b255818234344ae240cee824c0c7f6c5e7393e4ace0650259c3d62081ba050371b90dc912a52c13c4dc7bd269db2feb5601f3caa2d8c1b10203a29943ba72b3b4275fd1cb56451f3468e94f470d05c82eb9f49f9ff1cae0ce7ef32a59b94a471f667aea7d73548e5ef4f1f776499666c8529b4874e5f4b7a3e3ca29bb4d9ead1423ff0b24dadc268af8c1aa5f1d8346069ebd2e77a8132d56e65e61a82681c01691f52e7f6e1d03c2401169ceaa1a3288df4b7371d71ae7b3736f86d75dfc72b24a75ca2b32732e410ba234a456de2c694015ef7cf18df6250adc54ad3facc424c85219ca5a9d9fe10bbb32be6245425f0818b8567c396d638b5a3569747186cd9b8452af8eac8239a390a62ecc32594cc95a99edb13f3d4f7cc5e6f645f18e964cc7b34aa93e9b9dd5f6e264122ce1c73e70f9e04c40d85ff08800c6f3b25746e1ecd8be2d3a1ca84306e33da8c60691336ee09949060af42245a271940f4868c413e891a4bd986d59a192145e592cb4e0f972ab878d00731ebd22c3c76f49e8c89151811a154adfa1f51f71fb1e80e7726a2584d286e4e3f368c92da04f4ad5bbdb7ba59fd128e860d9a0aeb9042aff851a57965ad31689d0148712beb83de4ff030f32104e0c8fcb7efbc170a1ca4a02f03100da5de24d107bc35919b8b787edefa6f24a5cfaf13dd4dacff46e0566af31638ef5a09c81e07d60a922f565c8677ba93d1571af169cd83feec1a57d02d68e7ee1e3090063753c69a289e2c36ac0393a8261204b78dea0f7e81a045544a85060236b98ecaf677644250249cd93a885eddfcd4ac91d96b7e31a622c4058bf33981decd7617fe92c2b657eccba1965ad11495efbd1a16765cb6bbd277a28a212f1c0e944f272278612e4c8be660e69dd47ff2b8857718910233fb2b1508d46b46fc7c23d9585d007e4d11fbd2413da18e3140880a1592f1a5c902fe6d46824aa507243ac65bab8dad42d86c69949a7262bcaa5bbc90a81aed09cbeca7267324aad4f9d158cbfa432c76f714b48d97bc970d5e56b99dec7fac1af8350536cf69b7ec452e0897b538309d5d86eea519e81dbfda8c8ea8e1ce43e234b8b898fbfd0e7592f35df98950d9684d16a4dd08dd76f798be4fe6ec8e8445c758c5118f5df58da09bac9622acf31e6a898ea103320fe0e221b339a1e93cd16f7c56c0d7f16f0ecbbcd0f9df565ca5ef6dab8539a3db0f948ba7db5e9f09e7e87a978e02075ab2c69a10d2e1ede2b672356c57328ecc46daff31776d0520270f3ec8b4a448002404213606a4da2458dfcf2c4d905c5df0929b589633df02404facf0dc291ad2b0d237500b2940ca4ea7ab6f4f4694453ff9ada5567b2c5dc37aa5253b1006228fcd723fb39d73e4ff64372e9fbf0edb3073d89110a999de3460283aba8cbe810b14deed99c91bcce783083f3aea520bc039d1ad3f2d280958a6eeeaefe07a35d9f5c5ef4acd078f33e79ad4548ac551f8e59c7bba9f082ef62bc6d152ad131e0cba19f87e2a259bf5fcc18829c0fd8323178ffff912dfbfc0b01e77b489f32e557721682f2f10f3fad1e3b5eeff963d48657dc750f13bb100cd64db0f4dfebe1025f8db173d0dfa555a249ddefbd34327cd18a8960f8aaa3f0d16e0280d0aaeb61df45d69f5ccd36581140a8182e9a00338b4e53ab74f5f3bd504422ab0a65c2f1a1e1f23268f57e19cd9304a4412a00aa92c9de16ddaecb77f2032450033aa3a2a77f01aa795c26155dc1db69fe2b523ca181d46a93adf4390d0edb82c7b073a08cf1974a4b459123874b302af60998be120570e2af42f66b1142d23720c098ec761d1b8adaf05ad3c39e6165b6f568cee513211494e695b4795e89c31a4a542221cf81aec688feaf8f3c3bbf9c29e51324dabbfdbb95e0fe6d59400aa96b3ced1d4fca7e6492ee715e8449a054c8225782a208e206695e98d81cc70f72992f4514c1c0d76be60ef75ac966a582fefe6cb0d0062d8ba46f3b872158748d42348e7584939ac9f1adc19cb6ab0c76ec2e3e20b2d5f28c82df8c3c51745a029c72b8172ddede7cd66399a3471b029b531ab0371e151f3e87e54ce59293f0d277a0b3e1e0bfa5fb3542984170b1365254bf993a7e558a2e7080dd646f5d353b7e6d62a63fb626a8c6e2b80e3c86abc78b1c8bc141cbfcd8ae3000d45f401311caf42a16b3e26a06fd561457825b48e86d2455d3b51fcb0e2e8f67cdf38639afb3694e0f73277ff02541a4e578373a70b61d9fad6382142a7634eeea1cfb56c3ca62e56b1d6c6a0fbd6073847eceb490f11e7be01271a92b6857d4efbd01164020bd5040cb2d800efeaf9a3d014f816f4dd7457c30912424c2ce893a14effb87d1db6da9bc52466602c06dd9578b89c9d996d6ff6356ce8a96886b698d625787ac546fb03c1785d545a9c0b3a1240e6bcf6a6b011a00d5268c9a3a9ca3561aff3757c0223b360328e7f3cdb0f8ae10d9eebfdcd4d08f679ed84f99d080751b8938dd868df15b0f17989d25820408fd684201a572417b3dd6fba196eda48cdab0b370f5972b76d8aefcb843d7e96b9a7501ef141f0c38431caa0405d0258f97ec425231c64c8660c42c5e21c538e225d09a2eba57fc8c0c6756b0f212efadfc1cca3dd791e15a13a98d4284087347681f443860d33c9ffa570c8aaab8e598f7e8c2b1b0ba8940726b1192239a6ebcc5eb6eefff6f42e34e048237e5a7f29a2836799d3a907acd5356368962a96c08fa619c585d8408135e52da25e1c1596a66f0b08280c3e955bc8024319ad225b06eb097238fbbe722b3a633a5a2d799d0cfc7ddec548bba47135ef10a74df53f31fbb0c1859f72251ec4f14f13cccb2478204376768e84405a5a2058ec6f745e3909aa7d264fde4372481ee89a86229472f9cbef94ae57f9ebbb8192592f5f25643b534137e18e97e030bb6e86de4b1a316af9d7af9e68f68ffa22b7b94f4ce163c6eab9f328247eca8063929c198ad362af09758fad2c708a0db1d563eaa4ef696379f0bdcf5e214c8d01d44feac577563d89f010939efa86588ef85066bea30265449d4d71a456e3a0d64f737c42cec91c8ef4f3182c5f1ab35fc6476dfd07c236ca98704e867b13493575fa11c83d6989cb0e37bdbfddecb38f32b005aea3398f011aeec88f618797412f514b5fe321221b283435585b48d5b1b10ec6bd0330d929b8a883db03bc6774124d7809f65b5abec6768a11e0b44bd7c392021697d09cfd68f8d147cd3c0d3d8c5ea5993bf315739b27a847dddc6305b7f17c368979b8570afc2f50f749947582e789db506b7bda1c4b840b28b210b60d99d5fbd180090011a4c8925753f475885105bca3865e8eaba6c9992d8b62fe55cda04c1927b98527d8a6f5d2b2d041f5a2e18798abf34c3281317f1e24b9d9b548e5e8cc428f0489cbb7446979533ee42a63ee6977e066917ed1a7dfcf7751e4356238066fe03172eb058640e009cff58b125e0c04fe4f4125821a9acde4a9168df94102da53a037888e7a7a2948cc26212fadcd71ff02a19b00176cb31ab2aefc61842bdf6708bf77e70c6bee3e6fd3e7bcf7d2d71635b7d7f016c134fb91ea1cef20c4871c195a85eb8e08dcf07c8f939019a86df91c67cca7c48ded099c8c894e97989ad74c8ff012926d560b02c3ee61f0b07ffc7f99e270a94e8a54ea556e72f8bdde6db82ccabfdbc41bc2f126468a21304f6eac15621ea1a196e1c14b5ff1d4658eb79202cc0d545a4c5d34c98dc8ac132684161048cd725304ff2c1ecb778664ec7c0deea54d6de3088422bb1850c0efde0a7bcf13d33dc4ee2e48cf2a5816e7a6759a7b522f97dd1f599a9de796df78501b6ada69f4636af22157b426b990101fbec504c2f1301516bf51404d324edec928fbeaabad702b8aad3b9ed4b19a4fade57919051751913ecaf04f9218984fe4ca53c51f83a044d5bb266815d4fde0bb44326efa0c1ec26ef9d38c6ab28cae42f7b764e4a34f5ff0c7582b5b47cb098fe269cee5241237bb48d10489329b828f383a6c4f5b3adaf7dd6d3d5463c92877c9fae96ad0c3e7be4e916007a5225e10e496db00c473d308e6f91954bee8c465ac99add0f3ad7949c57549ef5420fe87cd0baf8e7c7d4adc351c7d2963f9275f8cfb4aa8e3800901ff91cc834df777b09283bbbe6d8e66464be47393c4b47c6a39d270e41bd124d5c3165bacc9bcc444f41efa8e658ad734cfcedb682c024fb27c3f97e10aa6830b9531eb46b26b94d526f2810e90055372f9b83c8d8ade8bba7c54ed7dae721575c6b96788b037753b15a857934f19d8a42dd41a4dfd424cb81a8e4ee3b9f7e04b721b9b6956d429ea6e0cd1cf8c13478ee37c1fe4e29ce0791ab168ed9de70b48fba9a2b2ff22317d765baf3894f6c2c43c22565ee3759e01230450d41841c4d3af14d59581da9a2a42c2eab7eb9f37dd9999f9ef565f8fff15f80a2adb33f1cb7bbc9f04005c44bbd7db88e88f9aa90a4ae63aa3d7e2ec701216bf26ddae06dc40a801bcfc37415533780a1f74037590cef69b5bb4e268099a5eb31d09a6acb344f626ea0e5594b90c9600cf4e516d30eb05de21c9bbfa45b8eabf7bd84481579085b9b6190badf3c997b98e59492abcc5c9ed1b2a7a03419b38bb6dcd47f94916cf197c03202931424ea9d6657e5a7fd5a1374e4b093f5cadc17b76f925070c658bd075e39691b48966f3c475fa69cae914867a172094965bde4fa0fd09258ae3cc988c86961eb9181d425e3bdfbb96afece42e475bcca4d95a89ba4f9ddcb8db5484d00305890bb12d823ca5e4d5ce97562d2aa257270afdd2e9f90cb7e9aa989440a430ac541fe13c90a79d759e20fce0aed378c334f48b78ef6027911b8ff0142a2b57620cc95b2469216f4f2182dcf9304841cb9e071c9bcefce01e4eb00277ffb128527b2ba4a50c727bc7cd9d7950cb99bf6f2d6d37eb7d5908b59360a9fedf288020f3fb31b57aaafb65655cf0c2c8b8aad62bc5a75708aabbdc16a7c61d726930a6d006e05b8b2890846db717eac008ed961ddd9b501b0ab9ad057026b56047bca08dcd96322420b412ab186aaa7affaddc5a75aadc499edbe3f204d088b20fb8bca9051eaf4ab7a1ae50114fe2ecb45458c6469a0ab8ed049fe2dd788038283ea4b6fa46804aecb43b9b40354c931cba544543f7b3ab70290d2b1bb2e10e040b25747f3c5cc3e8e04f5cde21a4607bd8f5bada4737fb29b32af19ed354c46c4bbef0f5abdd4d57d0d92901ad5ebf7e30497eb6f80a948e7aeb84a83c517564e569e33c773f1606c85cfefc3b54c60d7364b059ffaf8b33588335c42f2ab848d1889a41de95ea7d1f08e82a8c6e20cceebcfed390002949558f41e74a08b86a80175633c8d758a835f553c375e55c3fed4c6da3e9e375e2a9ce4f57ffa1c79fd0c2c3083a9dd45d8100e44da75f6f62585ff662234b8709ed4982f698ade6764aa93dce8e0e1bebdae70adfa22894cc7fa892800eaee7df6b8b61cd0055080543f6ec66ce061299df3a5a1d4f48a7cf0d3bef377b36e47f5187803df5f886fe15cfedc56a46640c4b60fa67dc45696f5850ee47e496cda9c5f5818967e67927b6207bee129a33226f6472375bb881b1d2123f386189c66d726410b2ddccc0080aede3d9766af43e8dd6c845b424cac602d5de9f88a72e98ec1f42b28bf61766cea830cf1137a411d8602f6dd7796d7fdc2781a69c1169c1c7699e2fa1549dd7ad3e5dc55df9d4f3b64821f7ffef8e0488deb91bc3eb74441f5550a3cc7fda87702711eb0b509a09dfb2242b4b8b64a47378b9965a256571132cc570c46ec731c0d669b49e9f06318657d25d10ba00cb794e865f201595d131efb3e19b2ec4a8d88f9ae55d994736d35dda4689bc2da2cfa3d20c883767f10780f879557ec047e611357af8264fdbd609c814a2452128b04254e1d4363090651f00c8702ba762ccdc165a396784819b034676f4e0e5ee41ad0715574ecaa1d744d55b5b54e9e7876fd4e03ebc85cef6a8dbd8f634ab29a161b7b3b6b5572a793425b62763868a2a13a7957dc36c7d601c310e5925c84f85d0b179afd882b6562d2c2a65e4c706c474dd144c9e6c36bbec9f4f0fa30b1f08f0288621ad8d3c3a7937e490c512cb102df880f0b0c781f6f35c699315f24ee63cb0d328bba154fbec7c4755949e2bfae35460b087a61c4699f9d90630478388c1f6b437204b14ef85e288b1d15aed15d551be7282fefdf4b88becb742f5914edf28c4da709dd433a7e4a9f876a9a34655743365f34dc72ae6af8527d4725acf1952a5237a9186991b74a202786eec9cb8d59f306089bba6b1a73add59cf8f794d3bdc5170fa48bec8a514c60d1d23bb06009ca09f421c8806a2182dd627927f4708417714ba21363335beaf9e549ca815f777f700d6d8320a17fcf6368f3a39944046fb767cf470cb2caf4c523de26057c029a2d4f3c8eb69a1b6de5d579460563520fa8754fc10e1441844762c78a6d4477b2123d81ac87a017db65bd75c4842776dc832735bde3272cee661d43d9980b8e6606c3c4856bd6a111076939943e5c30d599bd281506d3345bb51b6f7b36a0b05ca19ec520037051588266197a5da9fc0c4bfeac5518abdb70c1e40a5148476f749bccb955e3f4d67568ab3128a87b8f10968f5dd5444d731d7abc0533344896912abbf54608ef4c26cdcc88b5ef265cb2444a834d2f99a53617136ff80592464dbb377d1b60d3ab9ea589b2d9ae258eeac8b23a9ea4fa301eaeb7621aca1a01e716c1ff07ae60116aa69d0128b925a61377b8211956aa6348d8131680cd106a8b75b2c01869169813eedea46f0f3899ae11a2b2ea44bda7c22dce5032a3e5c50e3351c9bf602da59f47b4f6050c51092f1c5c28a644199f3a27a8dea9d43d3f55fd57edbc0c6ef089aae8132a11b8e421bcea1a5cb8c2305b7c16f3000ca67c9a635558a8d9705b55d7c14399864c5a8e95d1d7d1be767b2a1374f7a13a712df4f9cd0fec7c77fa734fe84f667129cbf11208f3b26311b3b1310e9b11673a9ba30104b86b466c3f0cc348c2d852d7fb963f4269bb777c78e8d7513bcbb0d179e2350bbc7d525574ed4152354a497589071a5b678437550c8457dec4c9dcc4206a4250a4dd86b3304de1958d4ae4b7b24b0b110d23e3b8fd842fa66fcf1c99d1d4b1f2bf7bbcc8e4b041b7c5994aa8465beafc4f4430891e831733f8b2e973b8b7840b26accb36d9ca0a72bf28bef16193acdd87ae781b3631c0ffc8420bb53e58735bdd3661a744a10e1cc07edc8b21696f4a335efd45af95ff5c9035b48358f0a760d08b1d55c2552fcc7396310281eb3fe3bb20773ef268e054f2a53cafb47c68a1546a735cf41260d68f2ebc0667f103969e0b447a028eff25c4178cbbc0cbc6c67a5fbea40e13956791818963979363403ac32d4cf217b6c2a48de2ecbcc7571245632b3d328149636792389c1c42e2272e717300620accf8d18c42e9538b2fc105bbf1ed3ca7d7edc840b058b9314ed54afa4058bcd75f1270b14d7563637d259319631519e393e07e3167a788c68790c42b077346b5e0415ff65965699e1ac6e94f21de31216b51d910630f4b29e90260d6c014714fe4d6cb19227e635f120d270ad1cd82e5df926b7558fea0ddd2ab15756e2868240394d830802c288c9b3ba380f5f1b732d0aebe1385de2e075437084438d02713a2b060c466d3ed9d0322131ec369ba1baebb0b9325798fb27dacbc42df89b182448800a6e5b88d84d0590ec274c172f0569bfa9768d8286a6061d85d4bdcf8abba157b453f24197e1d77e897bd30ac4a5c372d0dbcdff8ff3178efaa9e25a29c6b4762053163985fa65ba2069be6c1f70ca396eaa7ba64b0726fe2bb79194b581482a7f2d72a52b13f8808e2cc3bd64c5d79cc7a41c834b76ae4b64a344d40a24d0f69580d35a70fb843dbf86b9364f31b79b9a0fa13f4bedc8c0451d9d8c020fcb28890c8eaa8d04558883eb4ae3b242e767173faebb30067797b89b3ba230914ff34ee9283122d871deae9231933972d481438e58f69287d67b226f8fc37417d618b12df4e7423c4428d46ed26d18ded12caddd2705ead02021310cdb4b0cde095a0296c05e61635ca9d9cbc6e2d61061be22f0d226f15e6978e7beb67d89620af72bc5ac8e81b6109f68a5ab4c04e25278e61b6541c600525137e1750756aa3e7e21d543ebb34a23fec9acfda90f2891603e888e2b7d740d03bce9fcb0eb9966aed9e6cf44164f769fd3c8561d9972f9e86ba9fa8b55788e3ea3fac47728dd7f6c3dbb37c4eebd0ef09036df951e4dc775e125a7c880dfb85cbdfb88a2fb65fddb4f8b46449b142d482c241341f30c06dfbed2a7e39fa7f22937a918628bd109c3460996d6030268cbaceda29ead03e21f16d37d754d2af6da8666dcbc34465ac8a38fc37b55c2c3030465a1f990cff37307ca1bd9983f82287268e371d79473d8f41749f1cbb8bda820a2dd11463ce27228b7318aae34d036fab1e4f3d2d647d7faa01d890efc97ff3c17b2c3467851a0fa23d99fa3f5722b3dfa2cef22880855bd7a74572753b48cb4f0e2aabd421615d1b46a9718eea6c2d69e216d3d6aff3def6a6d7a5b034a997eb3a937f7f537f8e41924e51a27b0c3121dba53a161e9b5aa2ad18910555e7f2b53cb1d9d7be2a3b302df3979de511d64bfcb1bdb0582c4431e6e3f43f4430c904aed4292c86075fa9ea0abb62bbcbdee845574a005e343e18fe311f703bcc35c829e6cdb3987133d4db4c805b9dd3cc99c82901f5260cbbff2dc9fb0dc7f5d1e6454cbf03b83d55eb129a3689ee9caf34cd01daf653f6579d6f008f6be5b8881ebfeea9c048cbbf762b0fcd008f12be4b908d18292878f0cec2b2b274a75c5584a7850952fc76d6a7ccd836a591ba23df3495b56716f7c56d85fe4ca86e76424d8b45f105e6771a4d9113198c4ed5f95be24a56d06a4d891e05d4d83fa197a3fc381e0e3519bc11f10222b195325a1090366e7e303df8e2425d5f9cf386c0cb7ffb5a9a333bbb3b1f32ac655568cf88ee7a52bab4ca55c32eb09efa95a457e7da52394d448b45450a1d42fc0af6d701794097d345278f5c8fedcf47b4f282eea06bbd748f3b8e0598619f1f60da79fce0833614ae946d834242cd53afa68604a5fffc91ff61240f618b4fc6220e8970e8e64cac90125ad52dd68df235142ac9c55c25c7702813288130152bb2a1311f093ed05f308ebf0d6b14cd2a4c2d13e04b18e6181985ad146993c186a8f93ca5f7f91f5b75c07f0f6871f22e2c8e07110f8d6dbbcca9538785ced0dc3c6ad92d68d0e57e3ab46939b973f480236f20106cb8f80dc9cad0d063b401d9a9105f41f344d13d7383974bf8d57569e44971e06fa359fa9cae10e8bb6aad359805f60db4e50cf5d39b2762b664a8877ceefec41cd5b439f9973fb420d205581d5161b9ae09234ff8e553985f242d39c5a7efd99e66c01d8c7b63a1b1a12844a87f0de080056984daea5887a520fc3ba149d9fea9a0e257964dbc34773eb1eee058a0e1e512ef08cf37993c80775e394b471a8b4a8b3c48b6c4e79df814749e73ecc97000a5457cee87a38c548164cefe61b75b55d6c94f17b83301da71581d9977cd111860df427089b38648edcba033ecd5e7ca6a32c40ba2a8ca7dbf07e723e2502b301d09a5a1e4ff9939f9acd8f1e2cc046ef74225661487dd42096caec60bc2cb4279cbdc05bad91e91e1d4467505d1fad68c90071c08e504e5e865085b11868043fb9344534866eb9c2d134ab92eed5828d2116cba50697fd7d1a8c2597f3933f8a95c483744352ef78c7e616bfb5963892356ae8660846131e1608852ea29d7ee47f0443cd8a3862d412de2a095f6bf12cbba515221fa9b08a9360a5a5dbab6b885eb3fa8ec1fb2f09c8ae99c6320202eac49639a9308a224f9e336387094e02c9553109828579f8beef1505ff1bfa57733f8f90f555549b96df1a54c4ad98a818145dd860cf4c6073d87fc2253e3aaf85acabb4de3c0aee968972fd37b5417716b5594492219a399d208bd0b344bc11679b6ce8df4e4225587fccecf98c5aca3c934ab11c8009699cbd6a43e452ef711b79fb74cfcec9ea7645a900a85d4ad31d18f1cacf0e041301b6223342d6674bdb2ee59909b81b11627ae142dcfd2cb62b05ca23c3a73fed0a69367e2a36808283aa4e332077dd4e1d403e24ccd4a1213df95a79dfef6e9572ffde3a3e933fada1d4c2ba4aa18b6c2498fae93eca670609bb35be8d970740b4bdec9038a18c4ad89f4f87a495eca4811a49dcfaf94cfc87935f4b722193b5feb66f2f212670c7cfcc462f790568b11fb15847a68e4a9b7b58a3063862b08b48a28764bb378d923d3a5711740282bc5c36b6d4989c024400624b0a847bea8aeedb0a7385ad0c08ce9e87eb5b861e7ac12e78d5f2e8e4a3e4ae3c62298d900332bcd860d31024466e993e650efc56e4e0ce891b1edccdb2de7e5f49bd8467e8e676a855cf46ae4e5d71657571644a16b152a7373e6008e4f5b1a188075727feee1c21395d5834c76a1fa29b969d2375f3a2f1aab4a92c2870947c77a7e7174f82a567eda4133ca87e59a240fa986923eacf0310995cafd259bbfd5caf661e86dfad5b6326acc212ea5dbffa4eb481213e38e4302775027a8a041295dda14fa5b7a643dc97750624a7bf9914b19634365fb3259a16415523f4ee234f9d290aba14cc6ccb1f05e3eed874f27f7e8f5befc7e7f2610f636e193e03c5e93fb7bd6e1985c35fd511e24fbd3533562f97bc4b2fedf6e531da15a19b5a42c21fabfe31ba52751801c3cf09dfc83f55bb4a149b3d7a0acc0b5cd98bd4fa8e2cf5a2932b1b9d114d86074090bc05b00101a6ae8f4984cf6b59f59a12f6ed5f219f8cd690cae13d0d4a1d4d05504d8122b1b56bba8188d09a5eceb0b3db7e6d05dbdcf72c6a9e3e4318b12fb2d77fe59f11cff3f0632a976e4dc71955e44dfdf6fb4b4ec03149e7a2141e43c734b98cdbc2bef2bba5953bb42e63530a84cd1a4ccba4d0da6b9c96a87df1809861e77a3db38094c13bda19afb8f2cc8c051fa216b10cf5400a1b61f67cb99d706ce5e7038a1b71972a6fd895ee6bd62a66d72553682dabde1420cd1811d1632cd0caa5f542438289c9165715499752b32915894c15a23b5ca3ed7d5f94d93a7410fabe320f1801e6b985109251e4a634539b295545b6d442db1de469966e84dbcd5465e498503672e425220b57d2d906599dafca57f8157fd7a8acfa54bc30fd9f5dedb55edaa657d2734e1d175c92e96203ce3895ce41b7e79ddb843b48e995bec8743680a51024979009973ce1994e3731e7e8c7c9fb4975343dd1cb485546963c1a9f0278d30ac663e0a1501e2dbf3c99125cefabfd6e94d2396e17c2e5be6941d0ba57e33baa80736d8c47e3706c8951425717adf8cc21b1481fc06680a69a256d425bf2287256d66bed5a1a6f329f1768c1c4c6d441e36bae38ebfffd2ccb5fc5fd16e078f4d5b4f27333732f1c7820eca5a199ee1c40b9dc13d815a1da0d4c476c5f6f9db88a6a2c5329d15c5aa137133b923d184e53fc2b2b738d9f18e70663c7f85a779b771c7699c5757b5f2ecd0d3564244ab4eccfed9dd32e197e3ec6a7fd5e49f0fd3e8af88aeab6c9e82a68e4ff6f94377bfc6dad0d338010ecaecfc9883d12d5ef9210efeb610cd2822971976124685bfbf21f32cacb76124b64f0b6c060a2055ed03fb830500cd883e5a4ddc69e69a13a0d09756204678897d4503c9e40c481ccd986eab5ffd9a6af2c12420dd3f98569630eaa1f58303a218ce7ab2201796d61bb6296eef86c296c7e978d2224dd9a246704ee706b0c9380ccb1a32447b5aa3f580091c2f10076073dfb303266dee9e643ad6793cb4c3d26fca8986b4853b73e932a0ea18dbd808e66b925a970a71f5792bce182df31d61a1e4535c762e23c6712aff982d3cabf7f444c181ba601062b596e62f9f10e6938cc02a00dccb79587ff36eebba5e287a53a2d4669953df309aed6842db11e5a16742664bda6cb0a7fc66dd41638ff4afd72a49a53ea53ad88e4c772fbb34c4129f861a223386c8e29262f1e5fa3e367af217558b4fb89723f2300301acbcdff5edc7130dacce7c37b6130d434c841dac8ae523a11e4ecda43ee4248b7787e1b3bc1b2ccfce4e51ecc5538eb39c07ab3bb05f8e25f171c285b6d7371851da69e248196661a6d5130d64cdbf73f48500741857a32479890de451ddf722fd6ea2695ef3d93b5a02796ffaa2fa8cd67943fbfc46b8995afd59291e5289593c46982170a67468d19a368c7b27ed87e55dd46fee676b8bf4c67becd5a257d4eb7fed31559a7d9337436de766e82c611c488c9104ad48932e6dfaa8973b7e502635160832e3a4ffbd0ac6b05ae2b92aefc5492ff963bc1683d647702cfba6eed5fc9590eba95c84dd8d8b0acd5a4f2d2d2e9a1ad575f40de75d01dc57cb0f8e85198c59e001fd6484a173fb7fdd73eaa1c29a6d89c454276fcc9ff88d9cafbe5426311a288d2d6b09e87d1ef22e45a4883d60e7b80eca8eca57a78153f4c80982021dc2e0df2aba933154c531a4802ba41e5233fab41a0cb441872539ea652740bb7c36e330918c1b95c7032331d8b757e2f94ea709db757059700fc0423ba33c576de9f0dbc257406bd44d4261034b04e381ada553b841aec166e47129a203f46bf40f5c4d54146b50ddd097e5e5f9a6fd6ee3a221e437bfec36dedf3d5335c6509a4e7625adec23c8a0bc4a2541a36befa9e664391531e64c61f31b943111daf9fde85378d971582a69780be8d13cc7a04e9f079caf84183b0d4654f6852d396d472fcf226811ea23c767a578438390d90bd4a3625a3b14288b9601e73ff8c8e44d4e58091315fa388d8c44d19cffb7eefd70aa893155b39540e1039d8d7c3f40f6fee0a1a12d8f1e6fa79aef1b6f4664328ad55baa0338b256fb2df061bc6af14f52149d2eda9a607d07387854ad4b8afc23ea9372d22e6c9ad6298ee6584d80e53e0fe02dab6a3e63fdc93de4d0db623908761624973e5cf128114e4abd0a2a2aea5f180e10f437b9f868dd167d190650e6791c4dc20f02a71c5ad9ef2dad6a4608090a298060aadb677e784bd62c4c60376dd8acfe0bb7ba0f8c0dced8aff7383cdb3463128354cc03a70c5f0eee0055ade7ebb6a46af97a467b9edd6552de01e92d79ad23e6f3ea6ad8a52b6c1de2c4d23c68d0771540e9aae8239135be366911f12f0a684a06cca40d0ec130cdbce7ebe0b6b46330451ec92f69015127a6941c0644392250c74bcc42eddf23f91ade44beeda662b691a914c2c70cd67c8433cfe8c77d2571b66518ae43688135676d0d799cac79f0662dc569466abee1c37ec8897e9b610f4a3837a717ead2c74f48ceaa88bc2da97bf53036fc64b25ab41f9069986b2f82b4de173648f79fde5c497c276580cd18398b44232cb9aa1c530a21c73349f224da166ff11dd442036f0d2b1157ec2efb344f5b25458b07bea5a515ec3fb7dcaeb008441847f11161cbcc29f845436f24e53176632a5e4c304620d2ae8f33f4b71e58298c52822f521e847af6b1b6ebeefe30112060dec9f2a2a52ed28a5a03ed8ea4b1908de7aa5aee62b4c315166f8ef9c8e7d2f454eb444ecd02b0890771f7843fd9021e5be2aa7461917cf04facff3167a912f24b4b8fbd1df546715189c5c536e34a6af2d13ed94f701cf808c89e348bc2490deaf4ca8ae6b08357d91867562885d73b5093e0398f8f0ab20800e4e28b632b791aa8efdde16a228bade49b87c809e3125dd94c421e4b418e1e0965c62290eaa483c68c7acfdc4360d92aca606ee4e6c50826906dde66b41e02cb350696aab30d18227cb0a06454d6aa2557072dbbdc7a5eccd873118323530858c428c6ffdfe6eeea536a7bef1d87d16cc0cf98c0e91a086acd851d010fdb595b8db9f1c85267db328a728621042d296c15fc0819ba2fca26888b37a5a3e88f7262f3ef702776030e7d16555305cf5c757f1bf2f17654f064076bee13924e1397be14889f869bc6f9c4e6f74574a11f95d9a665b74dfbeae0530c8608c3c5669b909d64716b813a5d8528f9f3d30d30808f12433b318918cd7f320fc47077bf0832493c466c0fb935be7a267db4a8ea5bbc6a14bb434089ed0ebd13d49d32460093e215d1c711fd4464e552275cac2cb866516f9cbbc3c62a1bff33740f71cd44063eedc45a8fc27f66635194e7a7b2b8374414d632d58f06e6a8e66cc83a7c006e2b62381a6e1a8f8149c0951b48aee6571fe8f3ea8f131055ed75b40ca7b4212da8ceb677af841437988d10933a1760421b59f2c34fdd03f8bb4355693cd36888e3011faf604f96991f51d770a3680fbeaef313c5e77a6b38406424dee2546cabfa98c26eb0177cc4c23a26d08733cf144dd4b2bc5e1a3b3c2d24970551b3e28e32636f3120a4c67e679e701589fdd0d514af6c582e8ab2c45d0bb0ef98c4640aa6440e26f23efe241fc8c1e3ab4182ab3bf6b51f592481006861a77f8d35a8cd047abd414306ebcdd656e96b34b25ec4cbccb2457dcc15f1d6b58b72549b7e991e0d85dde7c078810af708d56e0265a1b78bf6b3087722878768292d3e02d8a2c9e5084974b10b55b96bce74f1ca68ac9d5c87dc3f3eff41a05ffb62c428029ffce3bcd0445817137371a6d2685546770091220f1348aa04c23cce10bdcfe70fe62bc01108abc6aa4ba9a22492dd1665350b3cb5a084b327697cca3f877091cd785bcda2cb08217b67d62a807828884885a7f793b4a6d0f9b087f22e8f1a0b299e70f919d87cafd5b11bda0387c9a640d5ec35d001e59e93b9cb57dfc94fba7d9d03e6b62f4f87c7ba23b6ff681d56d4549743cd2c3857c1f1f675653590f6371f2c2376be257347b87cf4d867879972ea3a125f63bb2f15da4a754570ac5337f532ad53daa5f7b2d3dd9df7deed72f0d63f5e878c663b22db0c245d195e04feaedff99bf4e214333603837958e156f13e62723ee80d22cc75f3c6dde64a85a236c7c36a0078fca14d0b9cbef80ffb82ba748933084362ac9d529bd290b1c9583350c81ae9061cb68b0e2d8a89921f10247ebdd0b28beab08197485906e812648689360075b5b78e53db7f185b70c64a6ff75c3dda164b4756c3d729f6c79d51be290b15ccd89abe4db45637e99de384bbc65975e62b6eda49e41792c99cc0bdf0322cba5191ae2346671f3cdb89e8fcb079d873261ec9ced55ffccd10d2fce4ab450df51859740a7f475fa6a51db4402df906ea5c621b506273ad8a420b380b008e133dc454cc55b961febdfaff9cef690fa458d7b4d8ace1d924a818de56471215eb7fd37499e7facbf51f54aadf56e0b8be6b0595557d78a4d8a0b258a6f347e53d095cf107e2c722e3c7d1d4e4f0e8e6118a24fdbf222c9a2c7d9f4ccfe174898471bc586d43b19e523c7fe7ced81c68540a9212b686d44dcbb56d1010982a1366ec3b317e92f88588af024b1aee21f6dfc913acba88c15782831e6c55678f1cf47baea2c9b7059c055ccd1aeb592484307efa8999228d8099f002d99479757d0a5cd1f609dbacd15b6a379b9b43c9d367818d54ba6c9a9610ecc2205eaf4bc0c3155d2e5b6d2fd41ef37dcd60d6a9f41f4754f66937b25799242fdffed92c8463482d4ecd46bc9b6d3ab5c174c37f0332ee9eab25bf5e40089c99de963dbd569af15d56caa4da44d5b43f50eded6a5e84420f901c39dd5e797f7c7214bb650c797750c95ad3ae39cb08b1694759e8becbbbdff5a6492b0b0a77138f5953bee1c7e232077b9edba6a5a267dcf5dea7ce35b99ed398500d30e6545e485b5574e211b6acb9c2ac10600611a79991329077df397bc57305bc90f2e95a2f36474f0ed3e9c8c2e4dd15c5c180438e87eb0fe8602184fd492db85bd4c693a84f6e129dcee74526de49a531d4261500d872e3c2c7092f0408c1f8b8642c8d23cb5e73b3ab65498a35cb693b3c380f404f80d7379d97e43731ce5d596c2ac7868850052f714de9d3d01e7b156d1d725fc002a7e7c326c905d178de79cd726f22a2f2ebc1021f4a6639f76dabef0293c10a45203aae80e56ab479d840604893594d004cac9b151ee1f3938f6cf261680b513e87da83a3a4507a6a3122467aa6d1f56d97879936eb4722d0caa6bb81620c5d603e6f81cf70dababfd4f7e39cbfa24f19cbde751b142ca34e57a909a2f075676c954db63f98b14c9859b5e9a7df017633468657d9cb3a182425d377bbdb9c8d91fe960979282888fcdb3bf08048bb3af6e797519cc96d12317ce8c006db35febcb96e51add80b8d1172bb8375f1eef89406c52e4170f7635a86b8ea6129f6300ad78e8ed37bc13467f2214a2324777e4f9110d58f2904d2f318945b5192fb2124cd160626be85ab833afe5a3f4bd248c40077c380802ad18b2511e2aa5fa07078309521fa5957b1c71fd7c8b871e478b5165ce58058ea7ea02a628622d3e448b2d099e6309d656eddfc2ec1c3c6be5caf609c1c43ee52e21cc25645d7a19dc91641bf1030078dc843c82c1d86371f4dc64a79bcae7a2478c6f0b595528dfbc1121074f6672af01426fefbcea801ef968a78e862c0d0359124dde781f80dd52df895a1162af7c92ac0deec8c383ef4786044d4e2aeb064ea3d98e80faf878b21309bb8300f0c3e901edb6771dc089a6e6082f1de81e69f93d82d0b489324bd8a0063866a46fe27aeb897425b7bb47c7a017d7b253966447dde19c10e0733fda7141862ffc1e3ba55afb4fe734b9e33f22898b3d46fedbc1723aed847eb44d74f65e5ae70e495b3133dfad890c2ebd79aa8227737cb452cdd86022643fbe6da70d4e17fc7164666373f5ffeeacef91772760f979a7cb2c3048193a2d199a9881665e9585dac07289d8ab6f6fdc14fed9e01c4f5ea211eda8ca65832f804a050839c5f6941f98223b03028e48a1655b0361aa8c14169b3070f86137cc530a559bbd9baace962aa12a8e7f516efad0f718f6b78acd6aac99be99c954ef5fc35bec5c878bd90a4979f849f915dfca2a21780732de20a3c7e95b3a2910e6bdc2c809b0d92faba660a7095618239440394d9a9c7bb7bc7866759103eefe69525525032e0cf79cd2282bfc5b3d925a7db2fd1132d24e6645af0b9f52ce406edf1c9a51b9ca5c80642636e761fa236efa78263c6ec0e8d290663396f80c1c1f1a57d227644eb771e6b5bd73c324e51ea135e744d2a13a683a7e1017ff985058ba85ffddad614c9faa3a9dd302476dfacd2948789e7f0169e81cf4d44c2a07ad3d4c643059cab0ba9526bc9934f62fd5ae1379f7c95d07b024ecebb5129bdeb59e14fecf62f88a16106835aee04c4b588b0632bd18dd4277dd6e2abc6f33466d0ef585fdf69f69c3259336ff86e66ec5ee7185646b5f628ca0d2538e75a1be4fa455cc1220b207db4c49f2914d08cb45c0b9f954f0fda6e20c9f1888b777eed1ffa7ee3f43323596ae02cf031b32f466ec2dbaf1a10309a260cff28d240c663426e79f9fe86f7008ef7655992d0a12cf766fd67da45777f712194c21983fa842070c8c51c451d42678ea671492b72832ef99769094217f16e37b45360e1a7cc0e1f9a7944c8ade56eaf675edc900a1e7812667a1fb8c8e65b9ace9eb8ee3c78c4c69e489903ecbce7064ba765ce22ea45061d937995efa0297af111cd30412a451c8f7e1ebfa426db82883d9ed9342f0a0bb26bb98b1b274188d73ea991cc5f1f9c6ae3ee8461253c64fc3d9de0720b5805d2e8569dff3bf02d53332919a90648783677b54b6b436994919c9a04c47115fdf9740ebfd6a6cd7cf160914842d9a84bf7163d6da70c6a158f7e7cf309a413b7d4587c6c761d346938658b49cb41e9333fa26f6430563eed921063264c000c0aeb19bc8008455f22336eaf0992b12df720beba98e0ba1bcfd73fb16aba85abb2a422fef052bb33a00c483372a74c12b3fb8522424b1929129df2c621e9ad78a162c4856ccd23197d6a1093ca6ace917491f0275a25c3cdc5df2e4dc2f173b82f2156d9a609cc9bc639793a8ef106aa1aceb010e50f9e43e5390388f7ef24990f2811ea709158b48ab87dc273f4d92b86178b7d3346af45d85d32cfff317337b07df1dc10a85fe7c3a1781c871d53b8a09017e16fdc71679e5ae68d7ad5e073082c790756fd127f542c7736b79015157e8dd23c91f4fa4a17ed44ef5f395b690238d2a627d5c3025103062da85f9a79648c0a86794e37f5246ca3ccef6bc772362eb5bcddd4f5aae7d5feaf9b8ffbed2f164a3da3a1eb79e2b7a502b0117af5c70d39dd0bedc032a9911cac53d71a8fdd09b17b9bd0b05e7378f9e1feeeab758c9832ed9ccf0c8552fa2c62e89c55798bc17a6228bd898474e62ed5c11144c6fd47d25cacff7833a1ac44d5a7d1e7ffb857ded8b1355fdf4c36009e8658178df0261b0f20ee6f85de1de2aaf37fdabc9776e988d390280976dd5495288f51faae7d2e94d5e4622d0cde6500e677197d6ad9855fa09197eec4f8480b0b8e69bfa4c2f81faca9bd6a501d1032a0ebd87346be2eaccd309efb0514af77de379bc18e1854de1a5be3382e631d4be2b569017cbb6d58f6950e4d107cac40df9e8539d328995747b854bc374e9f9173894fc6d054c015c8c32ef57396a79c1e5d78c0187099814ca51dc45502eb862f955f987a4a16c3ce1c12dfb24ddf5158625b538541d7c09331be06c4b8b758be50ad31b1b2fee81072eaa0d2221a08a2378953b75dc73dfa6b5890566b2d759b9592d021462a0ab8aa0a0f63056f3902a9a027cb8da6630fd705ea6a0f534d254e2a68dfa17a6012806ab589485151b41cb7f4a8ab5b6551273b566730f141251fc1121b813e6414aa796bcd436a2767f32f78886c6065ad23988f6f184c75d91fb4284105c9a17ca2898403e29a9469c6ff1d276a34ee0a96f6d04d97416aa70d49bac0425bf8ebaf3947602dee107b416dcc0f8308a75fd2c1965ee8b68c83356f1b8c90571974a84ff6efe4fa3cfc23480dac97c3532890367d6435dbe4f9f1dc78990c2db9656c2cc61982bec3cceb457c3e1041f3edd73228071bd6121bc0e7a21d2299465b7ec198f1e2d99df1244cfe15236667cf7ae16884cb2111d766b710850264f53116c39e3a80c90892b44b7467e18bff731d2974d42dbeb56d642da484f0a12d1ddbaa67eb45776153ed144e35678e7e8512d21cbce1704e08c43f082528c6416e64d3bc6a134e02c7353790e02ecf644c66fb2f07b167db585b93b3f69608c02393d9928599cda3affac65dfaf5d7f38726673b8b104fefcf1cd6e0e2619322257989707a6b5006cd2bbb2fd7b4583d0fef4c179cc29bb306bfe46e404d57d6ff3ed0e9a81f8ad453a90bfbabcbbd9fba8d0773b5541b0e1f07ec1b4dd54842fe3e5bba77e40ff2565b44d5474c9c8dd0b783150e3422c8dbb58d805ce7a5a7b5bc8f05598874373d92e34f7b151b33f93e5e7f25064e2de43e2535ccb31a1cd1ecd0ff33106ecfa2755ad74ca8fa390ac900628d7ac9fb1f8413010ed9b2fb24db9df642fd7e4c23cf07867ec5a5561878ebad76596d8e42d9cfd3e8281afc0cbacba39936387e064f7a5de75e80693fcccb5f60df3a39456238dd0b13a3391122e18b1f33881dde89d79a489b5f0d3a0fb89e85b9a6b87e4509646e92e724fd28161aac4a6f54d95a2ca5792b6a2c238d99655666b929bb8ef5374f522ed060271898d2a096c73cfa050bab5cb3c979a6d6eead4b06d1e1aff890c84350948fea9335ece8a165dd1f9acc216e87318d0cbbfb5678f38797ac3614b07b69f9bd1cef02f3f3b68b74b1dbd22d95eaa467f5aba572deb045a5f43caefed375893de3b7b46749324638134a80b81b8629e8c49a71e3f80fa53c926aaa84938ee4897c6fa923f5d9bfdb934ca641ec089df135d22a82841dbf9588047a286522c7983a28f64963fa5a4b5b70ff2c84a9eaf5b58bdfb78be492028d4115e35643a522e09cdee269255aa02efca535ee6e330211767bf1dc9023870b028ee6359c2b139a5486462394db5cf524765a58537ea4ef96cce0e510301372ee5c8ea9eaa4edcfaae68e3713750031bdbb148ba924399d000fe94455e925a53bb9aa2450df901a5246d540f3c53a14b5bb813b1ac885d45a5ab08fad5b7b19c83f5c7029f50765b0520a6f0fce0cea74266a8ad7afbcef647f44028785daa451c1bb20614a7ce90aa0f52c756e019852ff0d12a281a50719e30c7de909b9bcefdc1643039dc76ae4d44bb13074a580385572fea2bac4700f4cfc1b51b2c03858bf4182416ef8a23a504abe93bbad583f13a43ebaedaefa6adb1a351709e97a8e7d2916d62d2c7358dd17d38e92e80c9b7fe4bb11efec1361f9494ea25602fa3c27e6007d670c9864d72cd2fab5a4ccad8c50832153ead4879ca266c495ff6b68b1ae230e6783a2bd56d72d0c200cf5b7af76ea201d04702673154c9eae61448a8a5a39e8d7a3cfcd298f577b5aa84b3c124f5cad2030a521fa85fc426956b71d8c0d6d6adebd92552253634fa6a984153d22519078269261988c3be665f85da3425bef717a67cb5b9711ec04036c33656ed9ff1b68c2559c40da0a9ba49dd05c6e040d9f7a0af4046e0705949806d10a0a75acc8ed9ef710f58347a02b86793e0e0bc7bcfdfba5477ecadcbb45a287883c3ee611b1cfa9e7290484a0b826acff1a910bf4f0c27e1f2ed1e3004753a76327d3938eb74d33bb533d1adf145c7797c757165fa03761bcfb738aa73dbc46810e87c51bcfa520a3aeb378e33cce08eef5e24880959ad11d22614696985594d6d43c3056ffd20813ff36443edb4c4efaca56754733070fadb439dd08b8f4f1ae9b4f7296f3302d469b8813a827e211f405744e85743fbae55875472cd33d0c0380d4121b39e245f4b44bab9927f6640b7cb54c87e96982d7dd455de6c30540332ac5d01608a8b7da4346f54415133e04b82ab3e95f138f588eb184cae92b6e7f6074535fa8a00d2dea6887d11e7286bdc310d5a4f3d17bbd4bed2bf1107e2fb81d6db38e4a93eca5e9ea2a55108fee1ec93690c7ee19d8a1d27ef297aabd6ac005f2758449ea615d2b83696e11e9e61c2798c9a7622cfe2fac83590e75b6c52a8123dffc8eba6b628a9bd2fa559c263a8f97379f938b1761e69da9f30593f0060690f934961fe28e47403b0f2686afd856e6a7baba67251070ac1f44bc082653353799ca72545f7bb0ff78de334b1daecb55ae572fe42ec4a1b4e0ddf3ed742ae48134be2f33294586002846edc8ed8a1043ffadb04bb29eafffb5a2c857eab53966675a0ccc42b06fa2b4b1d6807d94975172dda64804e7333078250d29d71c08f90a939ef654d3afda5298119adac0e49bf43c5d844232b0af0cf317d6dacc65ab921fd2fe138535f887b2796a25b02401a8ae54eb882c465157cd70f08020a857a75ae106d72a8eee65b1bffa09d0919d1582e6f31f1603949cf1b27648204a271e33932b826faa62bf5c304c917d67fc1ea381090546148fec4fd3dbe60ac807f671b873e5e4e96fb9ed21962bb113581c8c8c8cc4c23a12a08aaa5ba95f4e77d8e648f47b3a84a2c4a1a4c4207011455f490c03dadc6da70f6bee86a91f4e8feb8771e40620f7515368adc502d782bcb2289576523dfcefddc9b13a35e8d2a65e06191e723bf1313326fcdd9b458a18ef584eca97595ba69e5413da98908fcd1ea3121ee39ff9709b3f71634b4b68f89ff2be1764bf5a511790bda2aef67ebe50e90818834af4448a97f070a0dde2e6283adc853a59426661c9f01fc437b156a5b7e941d0ffafb9b64d57836ffa6e0c42c68ac5ca49290a602ffb31b6c70de31c881170cbca39a1e1d33cfaf7454f723ce7ecd30e955c623e3a6551c599f6529b5dde52c75314e5ac74f811a1ed43e2267046b0e9dc7ae242e3c43a646d0c70ea3a1c574f5db198ddc6e4156fdaa6f1c2e539e633011e4df37ecb82bae72464c391a113aca1557d3da1a0fdc74bfc50e4031df346c561510e2dce2f1011e0776acf7ee36e21f0f49fe51c5cf4981f5d9606f6d807aa66702b33ae795f78c87685685e472351f621f3b5e3237e54b60928dfea9a78cd76b4743da7a96207864c5154aeeae4dee2da76dde94edf6f683629d4d5f5dc6392b7b27184dd35da943d79ee943964235141eef2ed40fae9697637fa37a78c52163bc68fb94c44608fa0a27485adb7e1c361f09d6b0d55f83c80f0f39743f5396b145ce8667c5705532f0ee9880106146a518d9dd2bd10f2787118781ca2f734ad55fb5af5a7afa943807b43416494c167e343c28ebc2870046199991f318feed5e11be88bc32e489040435782c24da47395ca5eb668bbc2d09c3ca7431cec1289e4c55c307553d19b2a550cf294ce9c14e0076493e420300e2f6620fd223c443be8865f78a78d366b23e9a9a2dba29800d2decfd7b558d0f9d0195b98979f9262388f69b3cd1145825416c44e784bda8530d0f72a6f63f726589f8438ebbf1194cb3d62fa2a8684a642601c703328ca30ed0191606da037cff9b22a3784be8e4d498d4f95442a9752a3d1c021e990e6c669d71b726f7f290040995227cea9b1c112d7f7c6571770751da17bb097c71f1d628faca2a975bd21e4d800ce6ea8f807dedb10cba1a739742f4a23f11b7789e222dd95fee132b3ab9262132847df841bdeb7a4b5fbafad1e9b622dbe9fb9821b4063dc24a73dcc424b146c1727551da6d6951dd6e8e2082d16828326041d627585a562692686b51a3bb3e74b2e36014834a7e7abbe946821dc7a7234d7f34b1139c3e59627fb55feb03d5a9f0886ddb51e067b504ac35828502b0aa248c5dfa54900231f430dd9f46992c5485af9ec68c1093ecd124b3b4ff4d751b36812a51044d443101e761fe7cf289234a69b3ec1c33b278f0aec4391fc24d950ede7a13fd2c3c10db3526a8c7ad26e95e8b0cb79a801f4782819c9021a9fb8847f37fd3a6af721f564e2a13d40500428fbbf2122d0c10aaf6bdc09a4ddb61b5d7c36e1b51b2fcc7bb418f0743d1404dd9effb2be07710d30f5e15ab7db991d9426ad661ef723c787532e5bf37c79b1f8a32fb9354a399e02259d53504ee5d64baec44a906249b4d024926a748b7370d396147c94e12c70495bd26c96030e6d3a979b5c74a42300c5d396847906571e6c0953a451e6f826c798106fb3c7e70cd702c435f529fa451db0142a1b2f55223a3c81c641b952541356a484ce9232d2b05d9ca2e2b4c02752ed8ddaeaa08ec2d70299c99ac24164145a48caf04d2ff7f50c62793798d2febcee8601bcc6a9a44df6480bc874c13f8216f456c88bf00b526746dabfccb972d5aff5bdf5024fb28e59b3fc67be3bf5056bc7683d9fbce7e0b50f40b5da39ca8a381bba4a46ec01742b962a1b5661caad913394e423e6eab6a53813910d748e81df1e872cc75883437fdf747bc844c3617bb650ab6f3d3e412fe31f551a217f33f7a500aada80177d206d82187e7489b2fa37703c0fbadae30f9573c190105f21e0e98712eb99b34694df62116d5f5ab06c04448be7d81d13f133404594d056c833966f48bdf362c1ac8916c6fbada7bcf0f714107b89fb190553d760aed59212a9464af6f644bdcd3d45523feb4bcbdf316fed9d9dfadf6413ce89b252cdeb143afa36d0609cf62b0a342ad4a255d708236ad888814d99977133d25bde4fd536d023b7e2e9ccdbabef0664a0d640dff9215a2d6cc7f28e8f3ece9fef107ee107c55c90332c9058695068e4a3fbc030b6030f8a25aa0ccbbe6efc98212b1a3751b154fe8e0526e5766131ff4c1c03db9e664de3f063e628a77d28200e138f4a877e835c98672a38ad7a09d729740bd0e2c9166d9b1c16b4a59edc1b41c2182103f4badd05aea3187a1da1f21c554c69d1ed0810f6a648b03dd88bcb1e9a1398eaa22a21aa27416916ef255b34c58215803279717a2a3ef6f147fefe4ce59f6afd548f8e002a1158cd00dd4d6119050a6a382594a14bbe2d32facaee22734cc10d7c62fb87cee0fcd9fb45a67c75459c49472b9ad3548ddb28db74c07ed7015e9413cd27a077fccc8b21c109119cf0c808e71fb2b80666015b77f048b7a25c91eeb0b2d2f6bf53c4001d31610a261a89af724a753ba184323e44151d0ecf953b2ccf4cf5a302da003d76581448777564abd47028b6ab3490f3f764426624a27b3ded387b63165b3061b09e060f134a5cdf81e9443654ff96ff83b16bb7f2c4e1609fe83656b4ef67b58f891bdcb7cc2916b3da83e8ee723c74660220acd187e45e2d12e032a12e9b8a3b4b7c5b5d9e51dd7c6817d6b04a50c7adbedae2e6e094e7cef2d108367dc34f44107f877c2022cdbdb25522eb130d649e354c721484bf22da2faceb85ceb62fbd9b762d4f1ad84824ea61564b0766bb2bcdfc4eb1ffb652ecaf3a69c411ac1549a941ab1de73909023c13ed417b93ad91557418bb8b11a3fce2b48cbec69d46dc5553bdd7e73c57ea738fb2e3501ea97f0b5828c0e456f01f379472d395eeb606bbdde92c831669f30318cadf5dbde3955647143d4fd23af52c3bcdda8b235e61cb602978240301bfc2855e557cd77a1385ab42478f104c40bb46b0704bf8cc20ee4480fd68385ee7820dceeaa1d4e39d066a5905af1c62c2a20d42c0cfd478936c239e51e78a3d3b7bf2feac3097eac0a78da2342b82ec3fb9bdea46ebed0810eccbddf6f7956de15c20938d6802db8b0ce5524e24f5ebaaa8f1c0f096085c5a77bc32368f5f9c54b31468189e8a69120ebc0ed8cf50911acf56aeeeb4600f03b51915b16bcb56fa5c336369315cf3e195e6ffa1ebc8d04d7517f6bdd451a31aebcd3e68a2163999bcf318a1b9aa381251eaeaebf293e783b74c191bb371031eaad20eaffc3b7c11382ec5505cca8fa0637c150f8c49201305fbd46fb4412d23db1acda3fe01752887612b754a9722043a7efce4d934a7be6e838d734b44a497e44503f5bb8f69a4657688346a00cdc450c0b426b435676e9dda67d53d42247f6e0fd21bd53685c98314887a468d34a9c2fa35e4da13341b0b5c0e0826cac32271820b2788b825959b731677026997af7384c20c6230df381c561a84652ee1d89c17678a9151fcd9ba528138409b77770268b387c92ed3db2b774c490f5db5d99333587f29585f954856cc11b05c64b96ba09a6a310ce72f0a55412d679f1e2ddfaf43fde0737842e3cf47169bde1fa9c4a4c9ea20604389f3f3bb657029984976fd027d481b92d356d7e89aa4bf859023258f7d47a605913ac4762ee39158982c521b7306ac574744cbb77d26b5224615854e4197f78a12baed76348fd11b94db1ceb40b85d7d928fd445b052ff017392ebe7f6ec4533824d1e180f01b4400712935c25933b8fa7b5148c85902382af2576e4cde42f6f903ec63082f35590071a390a05b6f43b067d00d9295c1b2f22ed50191aebca13df8ae852177b4a3bd466e51e1fb8eca4efdfedf2c3e19d0173a996a927f693e15766a2a2c1a7f612e961727edb79daaf2ec5c09b6c92901760441fccd64dd7efcc3ecbe371470d310e6d1c5bad7afcdea7d69d64d338b67fe7c664c41c742e311f98f5939a7f00545ce718dcc282a1260eb1ea342ddd5accad917af7a9f5f0b5e9172a23ba662953cb8166022416daceba19833d94749d01e7f56882e09209e7d126af7c9ba25c80a55417b72f78d08660fd382eda605a7a77ad6a68967f2c99f24c64f04ed805942c1e9287514aa61d2d0a10787649545190d33fef987582bf72a5a4bd63ed89a261feaa0efe94404632f3868e44358c7fc9c2dcd3569092213e86337a11dffd92ba100cd8abf2f9c244cc844111135236bef27c48601bdd43f70ad166c836f6a43fcfb56f260209eb763437cf0f187839eeeebe2b96584f362dfc239fa1f29146f788e110b57f0da30307baf5d1beed992ba830d340499cefbde04c120116abcb9d0618d95deacced93c445bdc05cd21a56118c322be3463f822e451c3b2ae97ce9eb466caf3e95e6759e37cbc3e9815ee4476fe0b6d2960eccc0bba55a581fe7acb4eb86f6942347148115333813c97fa0139e2ae2039e48a5bec0a76428e83b568a93a527686b5da0c139ef7927aed92ef61d67bf29755232f52fcda59a71828c394d07e548d1ea632dad6ea820a84907720ea27c89182ba2a13bda9e960ee8762f8224ddb6e3cc48251a8132b4523c5ecc3b55aff1e390e774b598a0057265faec640347ef0e7e3e0e0139fb68ff74cfaebf83f81617763dd5209337e7bd290bb1562fe746d5aac5e041213b518e44bd0746a4ddae34ad8a919be2ad432e365afacc63340790fcfaec09ff439ba081dfa36c338ef9fc9bf91f7e9813a0a6394be49a0227c8843ca49a4d6801b857b26f95a24fbcd4835e0d1ba78c51637d6d8c00d038b0e4b158feaa51f14986a638d18e6515093c7ba8db962cc6c23b62fcff30a81cfaf90d0eccf3fc43af1abf85a6cf9e4f672ba0d4c902e90aaeafcd564a6da7db8fff9522c5e4959cf839643ed85eeafba4232a17698c5d69cba1433bda78826887f42d79359fd3e58ea3c3446b4dced0614943098971c3bd656790ff4457514727d72a0dc1307f1064bff95dccf920daadcb82f417e2f4b4a8045000e81af5fe70cd27003c380eb168c59710ce7e8852459e39284ce376aca90e788a23840c15f086413c70441bb5060179a4e56734184c45d93fe82252639963bc9233f8dc73135aaa3b0d7633fe8cc747335aaf3516afe3598804932a291bf1aeb5da9edc7059002fec959374cdf38741f33cd0abe2d2187d718a93fc83f04f7ee88c08e2e5abab63304629d86ef39d013d1a47480eb68e27018d51384334d7ddbc7190d3fea93236867e9fefa8b1dce2d63ba588764271d529ce30d7b5913efae02c8af484180c6a477b02e192c0a530779042579a94d41e7961a2cbf22a8444df5dc7e059cc41befb7cda984f067cfc73cc18ba3c224ed15294a224a11b9a8c23bee564f3bae6960153019df19853b290eaed9fde3b137790031d852ecd321b2a4f6c6d3471e64cb1a1e5a0b270e9fd6f453de16f33e95a41b67902b1a294857521016fba799417b1c557023cdf71d9d1edf2a63d3125ef38af4fc08dde2d0ecaf93c74548c18a06f8dad0b4c5072bc995bfed3b47b1b1f7ba8dde9a0af6f7ea60b6aa35036578530eb3ac39630675ec074037e4f551259efbb0a6d2c3de8251fb369bb91c6647672544fecc35ddabc155ec897e5be0cb81bb82c2008de8679ba31cf6636b199b2de4a7f37a817dbb7ea03f95d9adfe8e42b59ee7c934608a7ad6b2e2dbec2f0def58b1cf1b19ed70d9cba9cfc0ad7cb1e0ec4750636aa4649037851293faef000a974c0b8586be3dc646be0732c99c0347cb7021ff55324d3b4207b688dae6eb8f62a04bf110e9db9ccb897f0e6b30775ba40b997238a1e097da6477ff07a2fcca4b8ca923b3235f58f2519d7a55c4e87de61d0e82bb0719f0bde9266e7057409775d774d0bb34a57de40e9d8a96c8e6e6eb8dda38586ae42f265c8eef4e915648ba5c6eaf5eb5ee046d4261518d4da4e51abad6fb40675561af808dfeff8de47743a142d4a904299a9702a77e4deecd9cf6d402e883d7b7e66cd90ec515474468633d9e8ba572f7b6714609bd90ca39a388af4c68b52019af0d68154042b4dfeb0d04062b6bcab33e3bf072452ca3fe1d8c6c39d9cd0ce1017396def831ea4809d568986e129c328b0a8d593c234bafed671b98813e15c82be09db108e13e0ee0683656c1b56e714812d4cd0a88bc1c608ae44b3eca924f0e67aa83c74522ab2b1dcb75c003aab0cfb06d56f84c386353acc0bca81bedf0a9b05d1600dbfc8d31c78210e84922899638ce901cb41e26a45fb05425c282f75bb78517a51227632ca30786c3d13b435b095f39539860c571358279773e413eaa50671294bec304bb150daf0fb4af0ba0fe784a382e9e6ddaeeb5351015a833abfacf62daeaa914f8317ae7def26e347d19bdcd9d75fbe2870469b92608d799b93e0462311fc0704f127c05149f0b143f7d624e986ed4abaf903f56178872055df7aea570b60948c5e949c509a4127764263970c355c0173fc1bbf307ed791f2b92c976ee98fd610b057ccdf16b2a4d98c0adb7c8134a146003be07e1ea72bb975bb88d659cbbd512f5d006360505e1ae1af1db51c61e956f39f01d73cdf19a7d7a80db7c8da6f91e412577302587c05c2a863472830aecc3fb63e53e0fa5d83c8b83486ee010e486c3fe7f62b4da13d0901bd5383ac763a62e57d84a18c14df7e8abb3b41be5f22a36aee27b2d6a9ad31eac28939632949c31cbaf16d1f2eadfd4c9ff0144175e35e42e1ecccde27fe18b4086024b89cf01922c37b0211e221e7e47f546030af2061dc303aed7abb7445870a204c4b059e574f6e8da9b2a258773d9c3825e5ec4c67a22a0119229ee98c2d1f8aab7a40cf458b9ee4a60b697051ed4d0135d30d30e6ad9bdce2bc09559765c9b2be76ac8a11d5fa6744df5a885b5d1ede6611a807210eeb5852d8a42c46b8fa5a21b5c27e45f45931bad9007a45b9c0c066611a9080a9f048634babd688fcf28a356baa307cf44632cdc00f976fc6df44e54c64c8f126afcb2306cca5387a230bfe4ec6c842f012482a2622ab3e3a136ab7b28e2eeebff3f54a70ec558d2a7ec6648a637e6acca13c8a09fae5ce6808072e90a992becdcbd267a5887059248b30b43fcd9ae28bf50f93df1393068bb28084576eb0f8f91b69c607e8cb66380fec0de57abba830539ee375bd3c6a0dfecf15bb5e78a09ca1069ad19087fd74e4a9134b0af0654b9790af7c2e61c15230ddc8e467cef7407d82acf360346f953f4c3fb07d7430639a5fbdf52683960f9f80ed5ad3f14646abb2e74c589b1f7dec42db9e8714d98d7d2d41aed93dd30b0a14f08b873256b8bf08a8e8578448f30e8dbdce13fc61541ac472d33d42330c1a79ae82293fdc9f9971e384e6e86064344fd3f938db82baa7f5ffe2b249a28814948924ada46152a35f6f9c1c63a3149410941977ae247e66ad49d8d9f998c382147ec52120340e3e3371c616dda528bdadd48f9d70b6a4ce0439aab732d735b63230ef2cde3ac746e0c613773fe52ff612c45e732b144dc58d8ac0f2c7e9dc2663da01609b56d152491f69b5b71029a89c7f98c70e3cc2f0a39164cd0ffa709ccf9bb1b552289dc7ba948a552796f9201fc52ee2ae07a9a0de98b32d1437fa3a66a46e72550a8cb7ba22bfc742c14664eb02f2d56475738d47aaad753a43d06fa65cb210cc750c7c1f3277f201efb12a3570dbfec86498477db8601f199f487a1d621a8585af27a9db10cadc8417d459e06c474b216cdae4a8788002e2d512d8e88aadee8d78d55a8a9d9ced679cfcaeef2916588eb2f41fdbf8a116d5c6c013ddcfaa4ef7c001693e4e90d9333b6381ef11b65fbd6f9bdfd67a6fbfcd5c0654eed4ff2b419d41ea09e57c6cb16d1bf799aa40f31a3ee6b13c5d51acc07957db45d1e49deee48beec3aafff21a5fe9c312fd00239a68616b2763aea190e7d4a11dceb11319db7af004cbac289738e5242e78c04c877fe9f524d2d46bb1cfaf4db0912813a4d513ef743dbd923176e037b94b2587d9b8e799482eb97feb122678dbd5460bebedb0c67032e5dea23b41889bee42c951c75afcee61ead80b1f427b11f599a95156328a852b0a3814625d03d52729b86e199bd4e37f58cc4f6d67339ccb5fef3c6040d364c95d02d380dadc5df403c3f8e6602ec0d8b9dad9550792abb16847dd30e3c232ff1ddcc0f35d8bf4e475f1c4a2043d017425568c5130a0546f5e6193a2d956744b8e19cf92cb0ea48de1a5da049c532d60abeab7e25a08ff0088054093b9e4f6e860749377c2d6113d53d61437e5ec83e150dd8925339f94444bef3efe538dc6a70a398108fdecaeb2a2eb8fd184295960603c10c4706d8e7a41f0d27b2651ec2a67a775836d5678acb82e22eaeb4eb9c0fdd2bfcafca6ecf5e90305dfc4e00a83b0e91595c91ccfcf4a6030bcefc968adb3b396a7dca1101256e3b943732f490c5ab7c1e0e7c8575334e89bb94bf6a1fb2d72682a3d014d8f952be39c9741e70385830f86e495eb34f6aa1e82ec8a5486718274b7e55b5d800c0b87ff85a82d5085fdc77f605ca27d68f5a923c11986225ee7bd6e62927e8f7c1695d003bdcf43e97da7f751994b3b5a772207b37d897f7e2ec058686e6dfb0c9ec1b1ae293641708bc6f60672a5675e5d773634de6e243d1e85040e676a2a55d001bc99568deb73f25780df71eac4a889aed03f9667860fa6597aab31b9991e6239aef3fa90b7abaccd8dde7cbc55866e24697efabf6c02ea96a7c82047986f2c30d6cd0b047062a64f3d967570d73334c90d26c6d01a06e9b9285f23193bcdab08308ddb6d4392a644117d090bb8bcb214ed646628e87020e0f62a8d861ce4bb437c2d5851ada7d1c512fc8cda87d7ec907825a02c1ab2fc34202ff4804bcc320187e648fb7f22056335391696a841ef7f948346f5287d3f143156d48bdfe546c98c4cb4ffda955c724fb86b5cd6ce7a54c69c460a15665f7778df7226f8d7a5592e3538115b9957ba196412c17cf59bdde12eea474bd0d45275cc24bbe09e1f2afd4994104cb803db3dfa2c83673f51ad7624fad468a6f5206724d1bff038b9f675110b69035a87871b597a51567c8f1b6a39959244409745ca57c6be4dcd0748a3f65ed94839375a0453d1f25c28b9c9129a694c21238a993dc9983b49feeb6fd15d3c54b48514262ed46bab554106701891c5108785fdc36e429a217af604b9f2fc08710e2c0b968981745f7229660b716ae659d5784ac994db1839a9733587ebd5dc612d48a61397946d35701bcf4023c8148feb6d948b7307fc45d7e1e51e3c3446e22a25ee6f5fb07d34d176cd7b2559ef43553a483e3b3312216fc370b9a76e80b01be9feba3bdc58924178ddc3a2e9964a2eabe654fde0f10671127fc0540b1e50f6bc5117dbd5048e001b375c00f6ba07e4a86116e841c035dc1ea350ccf4d8df25008ca5d20fd0f126d70846d6f363fe5ca69f0077027a6db49b5103ac62c9d794d61887f4d8b93573fe031caca7cdb6e702f9af3f2bb0b4a2c17d37dc4f69b7ff9c9f398ef6fd6c41d33518232e8f7cd7e6a5f42d8f90c888921d1a864955db70bfeed4289363df4531a97e6ecdbf5b9317839b457933c4f23624d5d2c96adbd4a57618a2886472b5d8f303a9d0549dd01dd582660dc8bb8feb1def240a79e921075d649065e675636a1db47f0c9f2a1100e26a49827c12b350d5a4333809e0bd1a34437a01db6228fb140b6eea210d55abb4a11b81ab628c22a7e615419108b762ecc5c586d5111ed31ee3ce9d6343d3f1f30b53e6cbac1d9745a1d949b16818a98fee7d5f25952ca615c04481bc4e9b561d567d9c2da4e40696b7420e7d1b0b19361c652bf1ef3b69117b4199da1ac027c94cfa1aa699139e0fbe958dfb94f439b29e33dfb4e1dbcf90d4f3c2e7e148fcd31f04977593386e2495f3cfc42f3e72ae916c3d69b644a0210dc84c69aa700d78d22dc5d84cdf678b0d77d3153ebc9d200ef2dfca0a06040674653b185d3efe51a9a4fd21e084433396b17c5e69f8656efc3176e1881dc81fc9b83cbac381589fbc751dc68ac484552d2aee9917c335f2c8fa188ff181548ab35fa4552ee840f5f5cea63473397ec23ad6252b9f7c265a25e0f2c021155c78ecdb92e1c74ee38dec8e57efcba7d8efc6d4e5a00d7371e7096626dfa8a1ba213d9cbe691417308919f74799afbfde19e7a1b2b3ddbfe8ef3f5c2a8eec7c3c989e17f825983b5a4d70b6f6cf1083cdb6e386046e5908b8b54a940a214d9ee882d63a2fcc49d199296a1cfa1d595e80b403aa6e304ec9e7bab862513767fe9368006e6ac94731d8f43d57fed5409da9a8abea9741f8eb4ca64be0fb11353d3b08c49c03464012efc378e96be5dfcdedc6674de3ce2f70d3e0eb76386a08a4d62f1069b3cf466de27d47161fd381e77b0234c9d1235cfb7c54ff4cf98a9bf550a83998c4d513d24c13fe90fc8b9b0b627c66299a529c1602aa5c18db41858bdd856d3a9798b213ca526ab305699e80692dae5981057845afeb66b1c0b929403a59306ee0def10dc27085e9f89ec5ea95838252070fcbcc6d79e06a8e937cfbec65781d9ab7a1ec1f18e26e366c5b1404a596f3c9b38363f7857f5a02e06fe9d8dab6b5ac3f499bf579b13d2a40c93b1ad0a2e97e9c3ac27216ab315d8edde8602c8e6e31803fb082e489e826cf0901566c6b312d3bc507de07476ec8bb3a0ff9c02e4ead9396c915824a92d0361e918411a948713d193b109abbdc222714a9cc493e2031b01645745a9d83a25a0f5a089c78b64d94844af549284d02d26519e30b9d812531142aa6726fc888a45db7af94f20b1f454bca840a300dca608624c2adc53d51451d109894adae4dc133792b8dd09c475383565e81cd5d49161e18291beb551e22047fc1a7b1c2604f6c2c5c5fa98ad893f910602c43fe5e768eeb4c0521507a8f1dc2185f412a5af07d0f9b3c0416ca9bc8e7ca2e5aefed56e31005dca8be908c240b4d8f312f042a5247fd233d463f4fad1f08b28e55c65f95b449a8707295ad44f9759604ca7e037576c4b03cc8bd4351e5135af88212422cd59f805c543bb2306de5d75f45981121277b46ce8db19345d25a8a7304e7f46602d9663ab7b7fc6156255a6810e892402f6ba006fc0b9e0ee7e56ff72899c80b233590daba51e43d91d81a0bcec4cfc3cbf1f23ff884abb16d1a80a903d6d859a0bd46ab8adf95b7075c181beb7eeca770f4c6f3fece597bec3eed9599449e3b04536caaf834b355d55244a42df84412e6abda0c6cd1b7037b176decea5b2d86d1761a14068fd12e4b4f6044b082a826954bc72c963f13fbb86017ac82b774a54792800658486978d51a14a2e1c323e92d58a56a05e399c0516d72e62d43f8aad3d979a5665c7e73e598fb2dd6dd4048f93ccc6e4bec0c008534573342922fe2e567781629ba80f80354287a2ac22ebb38589d8011bf5b892518e06a30671a7a9b84c4101ee394ec58f2e64836f0ba87fb6c1c7dc2676235897b32ecc0896c66f370a4f986707da707257c2a0a5dbcef22781d59f0374faa92d071c10238e136717f5f9357aa8670fe6dd8e7131a98be8b14e9ffea2461b62ea4fd19bece3a097f93c13acdfafaff4d61546fe595abf672e6a0a74642a49b02d07b250ad9ebf2fc59c3b11769e1f6ba8a9e352e2bb5bbd208bc4ae408386fb0362593a4900696c0052021154b6532a41c20c484e4f112991b042d2cb8109830c61c1c824b4dcc41f1d15d55d7393cf7253de770ff272ce01eca5dfda4cf2d4046d6fdfa1ab42ebeeaa401840797de659fd8d21e9f7f663050bc4d62cccc2fea047472a88a669f3d6ae577d91c6d07123a9b5500b593cc014e86d4a6a7ec74e3b49e05bd170b8576ef3b454a9f7bf2518a85013365d176857bcfabfd1707d762f0ac1d2ec5f056406c6491a70db70ccacdba334e1de3327ea029e7818766de910bde949f2c4af60c6b3cc51b565ee406a8ce667b18011ce4eb69736cb9e799059a4592e56b693bd421d97d6f978048bf383f595c7dcc0e2296df0dee7f40f7d45d3ee6140788983e57436554c462cbbe6c9f052b237d45e2b5c5ae3000a300fbfa02ae5474c6322ad41636fc666cec43b39f296d16c0aec3036ba8920d9bdd708110449460c84a775bce7442ad81e2bd3797833321dddb3e7df8f5c8661d43133a7b89333cfb0a3f897bbac62c9eca191755eb4073210cb922c37479da7ab89fead95c2ec7a54cdc1ff854e99eda1b47f3daa7ccad282d3bfc8503a12f6a4a6c5c08e8778295deb08437f2b7d9f884b11f0e18495b7db9dbe3ebe224fedc42c872063cd36f9e269b7d48c9bfccb44dce0d904ba5583c3c3ceffc87647b6b199b64c308940ccbb9426dd8c99b4c716ab2562052c74c9d70aa5e8d80b6f473bcb42b4f7a68a06ff3cb402e782c9443a1692927297f53d7fc22204ffa244d033d53e2a8396365429f9a48af146531ab9a5e88372eaf053d72328cc0ec8bffd86602862ce62cc259008cbae78194098e39b75f1fb0989041065e8abd293c732e12698722a21a75ee9f0cf21caedec0ff8f3f5f634f5da8ec62f4f2e163744140e065f9016653193c58c93c267cb30cd967e9905e7ef36bfe37a86383ac4c9a2bc3da9101a19b4de313c50859dadb021e3381704a45060c8cf598c1e4acf06d3f3919598c38883caaa311d06dc412074ae69333b592dbf642d326bca1b97432de8587dfec5d0ff99652ae4b19b385a88aca799a4ad9166b648baede35c8e9380f76c855fed8c726919217a0d098f17b515c7a8c9a446ac6041c7afb00b91f66d3b1b85870c09962da1f7e185034b5faa70ae74b5fa31d8e23dcfff4a4b8ebca985c29349acdc4715d22ff6154555615d8b84e71ae2aafc94f6d72866db31c767ec6cf1b546887f0b7602d7e78434f8f3e03146639238e9cf3cbe6ca5f65833dfbd7074ebb6cd2de959304745a68fd38ae39a0b93c7b393eff0b9fc938631712e52b44a9d97a1b38fd171486253bfc8433ea9785e5f8979347d87b34f16d39f6169257ff5e28c137888fdc4fa8e2702b79fd3f970aaaf059fbc6a5a611d73254ef34fc1203b7c3df02f89de8cf0d73bc12fc4e8769e507a67639db1275d471cde855644b15d5fe3873b6ae057bf287e6cf284921a44cc1dbb9ec94471ecbbb19652441bb17bc448fc54794797b7411a484f835a57db7e15021e071fa9c75fa0137d968ad3270fb9dbfba0be0492a210549ed9b4c8a697db542ce988af76a7980eedd844544b9f394d596677a464a80b54b6ee442b43a9eb3d901e2758bec4efc9c90c17c25dfe4fc2996ad09ea04d563fa7e7976fa82eeeaa49dd44cd2c06063d87f9f8f20eb2c4b90325bff3ef816926974837dd18854ab163ef43436dc8cb70d4b973a944ddf470fd183b244245865331770a003ff873b8f94472e5842982b5a8d4a6491e4bbeafba3907f5c71ed30d8b8f40d3f14545c1f59b1816f08a3e74a550cd7d6b25989d0938793db3ee6eaa51f8309314e073e3d3ed1e9a26ee3ccac64d249180280381437e1da84117eb78d797e3aa76a5ca42f43bc77e8dd8afb10eb980d3e78d3bd9c70a3874c3b78e8c7e94d3e3bdb9858bf817d12bc816907ecdef111270aa3873426c4a52938b65fbf407267f19116369d48e7127f514b017b7c285f000895cfd5e6e60167822b1bd41f83c68585d3aa399fed0340dafaaab6928464baf90dafa0ef4277facc4a005b62ebf509f948b0cb57e9d7e85dd518a2a03cf80fc9b300ebf69b597cc9e34a26cf4faef926b84cf810c56f558658f0ee3de3159230dd7e30bf4a6578e170f7eac003c3d249edb9aefddb3eb36e7472175eea1004897015fdcfbd8d81ab6452e41b880da97756afd395525fe2add33d1a57406ac6e7138a4cfea4a1807497457027f2d1fbda1113b1e693f4735bd7174f11d72a7ef92511f6471aebb7faa3e750edb8996235fd69eede5cb614d71022a8c5031750b354270cbb479416d16aecc4d988c4d0ea1607d501e373dbd0db3f79671ffab7e14f7c18796ab879fa8ced88ed47451047a4bc6c1a9a8e2bed67b9b29f854b89c0dd3b0f42cabec823a7b9bd95590e975b56d9dad996fff0c2c3c6c1c35314e13ec9ffd2355fda4a0d19a47327b899fd8ce33cae51e1b031021cadcfa7b8082bb50887e70c2c5db2049b8d841b7e2c1fb1d00f982177ba6aaa7c0d9957d2c3e7b0b3497d10e884bd9689711fc4cb129f22dc40e564dc90caddb5c1bd4d168a55d9d4da159274274b20d3fc213fe6ab0d5d0c6f89dc1bb103c0b285341791b1511c62a69cb9ba4e73e4a65ec977e424a515c6daf3f31d7f1a2df7154f419ed10430ec45e3e30c45699bc7c76f78a22dccedc8c0462ac47c2d06f089020b0b9c082ec5cf210e65b279b2efb89889095d5b428d4d02a3e0bd0ba9e963c33a0ba7d6f0436ea28c2dda904e1a9d7d02c7549657edefb1dca18d2af2d65c34b710972f4139dd4156259e4ce8debfc0e9fdca4d749340e2bb1449608c7aa768bf5bdc2aa769d13fc37c8b9fb6a55d3745dc81badef483a1029ac354c64c357a398627e8d018646f19be52c51a2a7ce031ec1653e3399044b21b5e5fe3181cd0dae6f86def3babef6e1e3f47b68b61b42056cc634d7715aa17f3ee6515e0beff2cf7100d6977d0641395cebd6226999ffbfed3a4f64cc6b0812e9a17acfd334b20f13b86583a74af06d26270b3d65bb4c11ed50835a3da492eca08999eb0bb6fc5c65bd45aca272a4bb775dec34ba3e7c6af47d50beb61f15134e19f5397f98fce0ebe447b29bbe3cb4bb8a70fbf9a621bfc9e0ce16a08fc9dc0b229e2e79d7915b09e691d228d3fb4fa68c8d7d2c8bfac275695ec5c353a3f8a8a2a1fbc930903ba25d85503a406d88ab89ee4b7a2a39c04206215c2341021ad62692c160bb63ec4152984b3b02842e1510063c9457bf446c972e2b67bdca724fdcbad57468a6929ca1c8d935feca0d4eb80f7813d12bd81c26cf5173f589445f8e3f20803707c160f6454396d27454722224947483d9f688a41dcbcf8af129a140e5095edd4c8f8bd7978e10c4f1fb78c9c52fef3da599aa47680694ece967c44d15f9f14ce71804464eb9cab8c737a296224f2ce9b332eaaae9456e7984417896897c291c6a132520cca4ed7eda0ed8808e3afcc43bf96ff5034e967c69239921dc1299e8afd322abf8269759269e333bfea9c0c2758dafa6fe69ce142d9478373289559d81c8072b07088824cd29d0766f39407bb4f24b7e4e3aabdf960eecf0ad6957f46f205fcf19b3843540756671a6001d3750440aed9201e251d7404f4bf4c4fef0280f6beaeb0a3412973c6add3cab1b0ca17f477df5f6836ba4f45bd7d835289d2b4531fb95b824d282cc2ea9381b6aefe851ecd40bc115b438fa5e1d191a36dcf6dfebab9c53711320c7d501fca5c794ef0f13acaf679a4bfb9be16f1643c0462198c5e825142fc54df09d0b64c142c924a291f9aa618d83651ea192cec573881dbadee5ffacc5bd9f753e63a7f5343944eee86476641edf778a788d2ed371c58b2d441793e11f5ca9b0df9fdfecdbf877891021621e634cef0c5374cf0b5636dd0a806547c463ea03bf8bac78317e8cb4fe12b1fa804edf06dd7de7e0d0a73dff187bc7a866b5ec3b3177bf39ff01c0f4f00a13e2b8ed519554a625649aaec9d7548d13587f4d7ca4ef460b7ca6713f091881567b9c14d5e5af8a6b0bbd79dae74c98ccc3decdd428eaed88232e818821e61de340451bd3f6ff9bfa3d24ff8e11a65393bf1145de1e9989a53dda4a830a18ba1e425f03b6caaa965b751d98033b6f8129672ea832e71c151a76d7996e029e53e6742bc760178eccbe2b0c55126e07ddc25fcb458e6d1e4858345e4ddbb1276602851b74d805e95f5d5df5d7d4eac96248d2e6499e7c2d446a2f8793659f82b53beb584f62f9e125db37f94d0a5d35786bed41f9d66ee1fb00d63600bc3a8806beaa8bf9828c7576c5a3345fd15542f17c46a0dc0827df55b7ba6996d6e9239181825abecea602db2328679b19758b43944d46c36312fc54c382356de8424e2a4240fc9dc6935ebfd43b33398ccaf9fa8047ab10eed618bd5152a54d2a785b2070a8678902c9ec8e34a993ba4fe373d6bf5dd45a7b094bcadb10e968ffc8f04b6553a3063cf39de788047acd83187e1c90eb41bdf5d395405f03a5881b67813b301f646ddd52010ccfedc579b2d3152c6e4263c150332dc7b766d129da453a5b6166313878cc63605fcdf0d60d28505b6474c15641572edf8038f11fec8f6874fdf38966e8db7ef53f76cd2318ee519b629fdc94851ca0f018804a23ac29e7c150211ba2c0595acd9242116dffed81fae1ecc045b74213a845f287da1539f87a674198fd39378baedd2bd2ac7531e14dfae5f4543752eb06dea9d87b3b666eaf32917da78b194fb8545fb17c4e85faae3e6c32a99f577193a9b357c55254f8801fa9362abc410c12210c2f82f523b1857b59c1a085b5a4c1a77c51ccb431b3b04962b371155eda17f7d4f5c433af37fdee3c94c9b157f4103f469c18dfb8a17c2bc8b82158d63ebeed389049e953b8d6f96f2f21525712a5b1a4b9dc02803b0188acddcf2686ebef903d0a3f2e796e290d5b14f785047f4a56bdd53194d5b55b12d487ff62ea7eada4ca5701453b9601f89f857ff2f4c7b65d9e858dec3c6c388a1f40579a9b07c7a98b0fc9ad5b9cdb6716fa8eab4dc5b825a0562cb21811b5b7def9f6436c7de29f95771cc05ce433a5797c8c4bec5677e403ffc30517aa06be3e7680df797fc13df8075bdd45d5b87629c3589f58cce5837f616d8bf6ee584351dee07231c9d5c4df122d89f2bd498890a9011f246ed38d55bdf423e1593ba42a8acfc18f512163d470b4583c80610cf3abdf939d15cc9ec625c284b4769dc3711c1e43852b2e26562589b6bc891c504e6d4951816f243bd18f0b953e30c6b69b894efdea5ba42c3d8ec6840faf18163232f8df473d9f6690682da3be039acf60bbc35c36bdec1a75d4f6f4abbde608d642c20f1b161c078eda4c61a4e506b1b49249ddc90095e35dc95e746cadbe13abb943ea997e34fb2ed90c87e9d8ff0674f61995388568b242fd564f2792e5c2dd3c8160091b97b2c63396f6990ccc3019f302fa47660b78ae09b41e6fd6ef3a0f042f897e29c7a29bd77d8ca8979c14309da4e5fa7f2cafc2aabbf6ae7aa8e02cc387a6c9e521613592101a74a66c662a9b6a5e5d96d74369b86ec7546aae9794577520bc232fed19424765e449905ccda45418adfeb2aac7429b90dc51dd8c8bbd78505adf90a567d81ee3a0cd6258f7512602542011117f98a57135f9141fb9cf97a185115f8340a4afe1efabff65c5aac62f6ad1e009180400ed10ac780b7fb72d94e91a57a9ff956cebf249922657d2aea55bbc42a4473a2e25f796b9ccc24455834cb44b4041a8413bd0de75e90e4764b5240f307712bec9887889a1c22ec4f5e1ec397c33a0c7e06420cf84cc1226cc01e4751145307bcee8aeae36a3a02a240c4d2bfb3aef8394012c593c9f7c0e8a9ed129843c44b5a69d535f48b9a76ac4121b32ea593012cae2f8ab293c5879805548bed5a092b22d7e7291d1c566de5accfe6d0544803a0df3c8d7d2e26976e78ad7189bec5484f2aef022a5c5c994098f9b8dc9f57f72dc17d2f45cc5f1882479183b8a4dc3e45763f7424ac782ebea2d699ded5039bf448f955971c901d3c08d38309464ad68eec57da81614fe12750c4439c76ee32f0ec4c5e8d77e5bca9d6392c166c5480f793ddf92d46346031b92d88fc20d0d288812a27609d17e4528a37889089136d005129b626c7de808a658cbcfc493eabbf165f554485917b42e0c0a890faa368d36dfbeb21b193f66fcfd387ade90579c70994ded2d1468ce39eec9685e21f1f6042d31f3d823f48bec704be12521675c07d8f7625073999d3d026bd070c40b7645a5d3573c5f0bf9e1cb562f7f988033b28316e09defe334f3327b980323501d9cda7cc0d0a18cbb3dab02f0c449e53ca483be5b34f5f17dc40d8c28e6dacbde272f2e667d8ff4c0575deb4535686e84040fe5e3c66247b4c42724ae075bb0d696d3dc54b6786d8a3056f5b5fa46b16ebbccc2c0744ca329e2d82f60d84d3780018c1dc90c9b7a44fe20f1c763d5009e17bbb523165bf570dc23b3f622634fc306095f896a805e05dabf2bb3606d6cd7288dbab4e6c2624a3cac7692db55333718be07fa87a3635e924b68c4bd87fda5ac175a5276c2aa70a77fb5b0b2893a77c166d5ce0980b9d058a2577c5674ce134b0e2aa09e86a9bfb0bad7dc254d88967ca326c7835b9a18b0fa0d33fbf1fa02327df7c9a87d63d3611c783e200b1dd6a4a2de6c0eb5be7a7079aa103a19a7cc9ca692a75a82bf24d7ce0b1914ab5c836455f8e21e43a0da77c802cc047ca6d757a3ed75b243d17fd157d9e90590ed1ca09fa3b105b237937bbab786c1dc1bf4f9c8d86aab0c4a2830c5839dadba7598930bcf57d988351e5c2a03d1709a5d963e9c073defe23ea8282d1ceb0f5299ceecf2acf3f5dd716ff82a17d53b28b5666665c9d8aee62c291dd49145fb371ebcb4259a20abac5fb0d331280f55bf9345cef92cfb73097d4299e8a97fad6d73e68cdbfb4367a29eb63b83109214ed7b881def488491293a58ba3bc1cf930b440b98b722b4ecd6462b62bbe94d4f06148f6fa8ceb39f7477d02382c5bd87bc20ff0fff66e387a3a16805ec8fc6518b6f0450273b591849b3c48c31bd44f37bc034c51790b562eea4a59695533817e744d777ad6efa27331b54a70ed08cfd5e0399f5c6f1c75e11110a330c860733cc0471b1b39a47fd24c924f550d7f328317e7804df5cac07da173ac511c30405f8fc5cb4842e2085ffa9c4f6911a0d53317d6be33e57c901f9f0d0d7286e849a223d78e14e468cca8e1c2785b00985ff91778edfd571777e14aafb525d6eb98d76e5443d226f12197fc26d75a07a16c19b7ddd2027bf0841a3f28dfcfacd51c7d9e487b8e1a8a5d73ca3e228768dca8178c455b8e7fbfab21e6fc4f24d039a12544613617f16725eaed239183e245dc83d139161bf1d7ca1879122dd8e331b9f290aa2eb2bd882594494d021c6af22bf8928e4c9f6f6c27e82449f72c90aab08e1b9a5e2729f59196545e4f820c8c17190faacb41321626c86b1cb5bfa8828bafbf1b299b290c248ff7c73699380cc0f7f4e3996a7c13e81786983050ee130d7ebe2853b9f683b21e098eaef3c1bd0e91823c1170823972ee5738be975c8603206052e869fc8d91dbbbb2323963b5a5b3de7390289dd1bb0f9a3d10aee8b507e9ca8c45d0c5a3a7c173e6a85861fecf21edaf9a8a25413ef7a4fe45f9474825f98d9cdb0e8164ca9dfbd76d8bd813b558dc6e5cdb850cc817607f8bbdea632d9eaedecefc2bfa15c0706cad93d792ffc18da9e6fa175c04220c23195444bf2dc5808204ea971121dd0a8b94143c29d67ee8ca1b0740892fbf933f85e06a8c3ed395f2a48ff105648c64f6b048452b55fcb92963bea15afd8ba88900b9a0710c8519ed4fcc35e831c6557aca10c0429652c00a5f572d5f96368be0e9fb49dd62b7d16a4d5a4f02b372cb376643bcda1bfd897a46dd401662cd9fab231f7447dd5de144e6e18e955f13e2d008c765b5c08ba484f6e3b3678256b75cc98a31a129bb16e2fbf9ba8ec8ee86df8f06247c3a04b2bf8beed53957659f36f75253a583a8669735149dd9ab2c9ebee49632ce7e056623d1161c23c681198d3eea74d5b3ea69f4f3fbc0a29607cf16b65dce5fb148307d6b064d28b4673653da5851ffb29d27efd0064f0e680e122ab68c9d9f95fe147f604fce72080bef99f82bbfda41a61eb443f6c6f9cedbcf8149d5c576f0118e4f528d72c0558adb2b8d796803cab995ac8ba806bd3f54618269735ef5b25e5b54fab24d3aa9ddc1bf972ac41257eef7cad6f77092928c7121a9938def7995dcbf04e8e232527e0a55db2adade69de3e4b3f96f2702b90ea6df35d45dd1129cd6f97dd28734cba77247aa5dc1d109e68c06546447b13396de476baef4d1315cf8719ebeca0ca3321d681dfd997227c9e648dc5a913d9c192783c6f41daa4333bb46f36ab24e5a72a1f6bf77a4559537465cd69e6bc472df4ca48d6d38e61b8de551bc2c5e5c2961cf3e7a19ddddd265988f015f9147ff2932f41e07b3339c84edba3de3b570c932f65ef78ba75a77fbe77f49da7ff4f8e6a7d4c0a162a30bced15969048cfa45912c2149d86e230a4c7635c1ea1dfd1e9e5b4647eceefd603538046667bc8055ffe8f22a4a49e4e469d0455de5159a54a873af13e698e5455322d007e2c93c7f324829ccc94c2fa257ddb5b7144c782a2d32aff242d00f5e58ae7cbf22fa3428967156ea1a2adf182e13a39ca5d19f78f71fc6c617721e73a70bab06acb104ea9e9c600a5f6ddd6edec5c13f2f1f5a3af311f6897e29b68d8775d98eee827a0729cb49f001168c3a1ce7131e5b7e2a4ba30586559705e2e0c2855d7f94aae7bcb508f460789e4852263bb25950f3f6ee659f112d724dbf51ebe4c2b8f16bf7a465ab32351a9592b832a86e6ff60babd7170c928311c47db2734e6153feb81b58db65ef9e693909437ea9cf753cf0139fed8ae920b0e32a5599b0da9a1a1dc1aed661e18b275f3f4a533ba5dc5de1105819d03532641a728343d4a7fac9725515f46f5c4256db5124480dffcbcffef8fc27586892f56a88247021c6a287231196f176237fb7077f4a6bb5e6e54ecc16a3a92f639dbee539c54229dc22dca2f0f148455fd3cc1a7c96d993c5566fa44329a92fc2a39fab21aaf0f0e116c6a9d2ac7f67e3f7e6631305f0ec6fab0a92e3f06b1058dbe841d6e85229957e6e3931ffd8046a2235d9b618a7bc3c11ef1201e7c9380b9d0fb7c796b057572db78b9086d7e779c945652c44992e1c52fefc1ddd56559ddf37788fcea5555e5af4457cdabe9b95baace791730907cc81c1519c7bec690c947e009afddc179f69964dc0649a5a1831a8f5cba8db5984aa0d8cfbef23ae77361f277d8301c7e2a0cead2ba93261fd1d85544e8d7ee558f06a2ce601d123d5b3847377fbf33c6a6b02791b31f7752e0f90715196264330916cb473eb09e49ed4bb90164e62ccc6883a51e9755ec14aeed527eef0dc996c8fde111509e60023c0c0d1ec018710c3d005c06990847a861328809c8a6f2af65fd0ecd5231f92173f560abfe649de9d9b3cdac114cc213463167e5aa720867ca859f39f52b110381bb82c2b9c1961add0fd75041ec575a83b8106541956e29c00f1a56da1f7e9ab10fde2f87c8fbd82fb7c64c6ce352ccc24ce061ae723f1b69344ea15621397dc55e1582963c1988963d1c6b1fc167e64245810274036b982eadac8e80fce96a3cca839f65f6383694fee113ce8a443b940f20acedfe416ddf3cf2f47fa6389eda6f17d5e1c17cccc72ddf02378980ed16da1aaf035c2594623d8c062b87888db7644a192509f78e1666cab00931a665c95971e9d58b3f72be2132866c6cf831565d3f6fdf0278f951f337890bc8ae798aade829cac947d3de939f13d15ec7daa603624ad61cbf40d25be317b5d35f9f399c263ee1c25af62fc32fb35687febbdb698b888363587e7912be3ea8d9954503f49d7a812a3e510f79b3f8cfb998e0e83d267084c351ae5cf541280c988dba7eb61d0fc687134ac149eaf9a3593921a9ff2c68e89a42396ec9d7423549afca7a687ed68802efe3a6310227b3fd6dd19b6bf41e8c8cd6468a7b4f6c734ef9a1a886518fbe81d29f54ea0ebc877e553db919a0b39dfb267a0fe77295ef944185fe77f24152650cb6c6d063acd2720ac1828287e642d0226882b63010c5dc10964c4cde37967d2e6cfbe5da1eb16e87045db1c68653a7f95ea5022f20f1a359e0c205ea77089cb846228236d441c8bb70118967b3f924d134b56947a7f8884d888eff107eb296f37ee621975a3e027cbf4f2698ecc8e385a2ee2902dc4799283fefad96889eeb0eede3c0211b61a03bb93555986f076193ff3a7823685ee6a5ecbc797c561954d90b4be6218137aade54a5f88ee02b4bd74b48b0645b8ef4d8e4fb8a08d150dce16bfb5a87d3cd7d6cb0c25f5fa27810a0d98f9361db12aa06c353cfcc43e36355cbddcbfe08504cdedf861b8ae2cc594aa048066acb16b7ea6b04d3b4f6bc723c94b49b1382468d0a26da6b08c906030a73e4f6b13f3cc6583a9ba6ccef9a2b942745c8c5d294e6767713290204432809387b33f98ac7f4f9ca7ec9592520c25b33c6ed45558e936a96a5cde3c758e71e427b456244379434eb8cc18bcea8c8808a20ceafa5e75b350802f670eb73066e6a097d30e9e336e2c187fa5c80ca2a78c370d46c40bc5a2c1ba80fa631f037c334974ff4993c81c0faabb3fdb9331eae8ff23b2722314fe5005c7ae4d485d92bcf442ad347d2ceb3c471bada982f07ba397f148dbabb0dbe8385b8ec46b6f79ad97bccbb73becb59541f3d7ee70ec7ff351544f71d259f8b880dfa61dbe583e06f71eedb0cabb74ed1578b49c65902eff9280f553821334dcfcf21cadb8a981a2b06cd379daed5b8946634446db12e5a1b1b2e1e8a131c1b67f940b9f5a3022eb5a977f4247c1de693827e1b84825e5a319cce7ad253195f1a004c998a524f3780a09c5012245dcd46683a65e840b299e9e4e30f1de4163297e12682d9136477d159cccd6a2b11b29554e900374c8697f6f608dc8e4b550d111e8222a5e5620cdffd24bf6d198d0dac5d6e707e6fcd6f84b0b017324587ebf29898ee2f0beb5fe80abcb785d1e1251c5628ba56ea2e370728e8197b17035f5a59ec285ec4ecfbbc37ab431e3745d2178cd78cd8badb4dbef59222099bda3786686a463f527f3c7824aec2a863927edbd9f773a3308e5bba67517e0e81beaec66662eb53009eb8289567a153609fbfb6ddd13b69e0dc8068fdf3bbfa65d5cfd31353819c7a896253b201b1226e3f77ee74c2448302f5345a3cc6dcc425be7ad6aa0df5a20d74088664fd6c1cbfc75810a09a3c9276317660536b35f07f04286348363bec9da42d13b749c6d42501261197d5025e013ed9fb813a6d806643b6865003d0f793f938f378338b6bbeaa2dadffc7df9957770bd5aa0a286d75a693b1ec1aa351a30ae3a0ca1e070946392b5595d78676bf64d729c5d3ac943b2566b84512e206ab2809e2e8df4726c3b9edb079e6bd22f1f98cef2b4f2e3aa4ac372989da30e79a6126da129973f59da20c73719abfcde0c96918ef6407518e4da2da93373206e4814c2ceb70f0f41ff6787c6fdb9a9a66527a62a13bc0be5ddf49dd9e8585d1ed25fbf38e907107b9d25c2b2eedcc4be377ab53511f8a7ebad5165382bda6230081cc3fde0aa4ad6ebd65cd29f33c386864bb687d011d29daaaeaff003b43db101fc726c75872b1bb9ba874187d4d32c55d0d9e2eb38b1a7c18e110624b2383c981da34cf7494ed343210ba88359a3040ac6b032370d867f984e779aab19e3249cb60bb40a37dbde4ecf2229c51405d018f3cdc9684cd3681dc5738420cc7a6c7e416ae82308f20e3f5b63b623c0fc24e609427f1ab47befb6cf300d52e6cba129936bab756fa1adff005fb39c931a4f359b9defee8f053f8ee00821f24328b1d745c0210e1f9572c396b01027d21f7d69fac84d8ffd607b2b20839f66c11f7856b618b17ed35d38b591487ce7e42b0b6c7edcc0b0f91233ca4462c47820eb52d5b3be73be165c19bfbaffe8abd7b12705a3fdf4c8a4364e03ae90028a81f32921b6a6d97d6b06e02ad7e0db3faac3d46b2e06e95aa5e5b77de0264bd53c9983079bcfd0f4b48dfc8e0e1bbecac5cd2208663e2373412dc57a3791bcc69d5e9e7bd54483a958c3170aba215f92aedbc2abc6a5d78590bdcf8616ad8f109ca90305bd646220a89a9e5d5745fcbe68c501a42c64d31c90343877147a43512188af38e482a344ef40ccb408e0ef195708432b4513e0653e415977d8322935ceab07a156c39357a4fb910a8ad6d98bf0ce6fed423a9f75842cc522bbc7a104bee4db5036aa3f02b0393d8312cc44531b3cf48a6042bc524b7db87b37be2777b54e3a6ba60500d860ba159b8a46b8511b90a04fb856850115e05d78e4c6b84d470066400d5842f125775c0ae2fc904d982f7c1487ef087ae2983acdbf08a160a72b4d1814bb882822fb1a12fe80d8d8fd936b5d147209396d534abf68877144675b4c0c7dd220b748aa8cc8f4d9df66c8aec5fbcd02e15a4678f34586ebd85f4a65109499f1ee3c321afb86400ebe6c10ffce022d107eb6b3255c3980cef5c65d1352fe7da65ba5b8202b89cdec05031f58fb7573aef2c7da3960413bd6a1e0e43e8165805aa22aaff4642baafae739d87589d0b0b51271a23ec99e16e3ace1fb3f0079805c63db96e12f6e8e87655ff3eda727579f031b946eff22de046b70af217d730138b7c2861b24cbef25b859aeea24e977e9c4aea82c857fed5aa20c9f218e1da80d338c361e65b40c5851f85530d25dc35df83c2c0037bdb2324ab3f778e3fdefd50afda4583035c9438408eda05d7818590d8413f73fc0c4c1b3871d4f2f28abacfc387517e4d0f062282dbdac5140917dcc7bb7f149cd3017043c18eaf4c93e3bc15e4739db7a52f46347dd54faf93849e8beb7c2cd7c8468a4fefd3e8d7ab21669b020bde604b4ec9bc958d17a15a5c7519a46abab815581f00cf2d9bcef4a2934bd61e6a5daae46ed2ae209eda01f66e2d43a5dd1231232c2cca604dc2662cf1893bf52aa4a3a84c9fdb959e38da13174f9e94ad01e8dfba02eec07ef7a263fbbdf2a1fad7730521ef78d5484a0b50ddd3f435b13d1127efb924798670a0e62b1d9a141c47acccd3a14bd86d06f127cf0909edacfaae1168ecb5ee5e88979d4bfd4cd43f8c485b3a887597f1a6309b07de1fc994272e5823e54d9c0ad59a4de78eaa6fa828236297774d0e30abf06df016dc04d4ff45a62e9c0521a7178b31635dce32c26090b730d02675a3cac712d13f85ae5d331aa1a6bf71ef5071f7f3bd3cb29392682cda001473a28fb7f140cdf7b6776750788ee0da043f991dfe5717c6a72c47c9f8f6036883fcf3e93fc25c6a368cafc2aaf1d76c4fff92e0319e326996eeb2e856ead5bd18d35f70fc0ef90cb4b7ea7135cb88c8ddf566a364b65917372a1e607b16cadcc0c481c76f1b9b05ab9ca8fb272c460e28488f6d24f5f71bb80836e4ce8bdf3567f4edcbf2bf38c32ad708b68e43d718aa5e7d1852af2cb52c3af6724bb6dd9e68ec4ee401d783ee7d5930f6d288bd572859956654d9dcfd9776bcbea520b2ba2971e0e477a1f68b7dce358e93ef6fb83794773fd3b06dc13eca9108eb611e5bdc306a86676aa199ebd3c69d2a9ca7e23a869c8faa8ef5e71b98d86725f8b6a016d800b1d101938ca027f5ffac4a0855d6ca90ffb033a36c9b4b26baad9ead0c5a2aa17f3ad7150131d3ab7430c2cd1fd1644b862caac5bbeac62df39918b9068486f38a80f1ccbba423afd2c81b8ae17947a9c6e1bbb31d0366b61be322bd2078bef67b1b34d353f4341e852ef34df45520c92739acb6292bea6a71381dda0084d8178e1de19451ebad6b1161e342fbd5a1db413e04bd767ffe91ff48e9ca9b87be785a712de65ee2ce37eb12edffc7c86d5bf3b405d569273b2068f7b01b84bb1bdc3c382e37bd183541f3321ea158d781519b828f74f6d4b54dec6e16380f068d569e7b00fadf9a552a6decc0e5a60df999be0685ec65e2acf6c9ea5d4820756a19eed3bf1d57c62061f597d5c4d9dd081b16bd7a56b32ddf94c951e8212658197b8906b12be8010b2f44f7858611209a654b3ce78a980616035daf2999c5a84308eabd3b47bc159cdb84d4f2f2760f2165d2cf60688c76c6ef576172489a7b0d6ad6c9f6efe2dfb2f9cf43daf8c9c6fa8deee478289a73f8d40aa018a295dbd02212b3ba0776d6e369784fcd97af5882fb5e8abc0067e0e3b2fd5108367467e036deadd0f0a076fd69c89aedcbc3256ed70d849dc1073eaa790f73595b9205313d4e7670e56434d8621fdffacbed30aa73126d990df656d27c6eb6b5a6ca5456a0ba93f71e9d9e8eaff3ae963f9f6e1511febfcb50c31578bfbdbb820ffa29654ac9bcd67138e5b1d7cf6f17e8e19dbb679d86bb4548b3bf9fb3119974a991c54de0d2e90c2129403b2ddfdfa68ad24673e6f663bc14ff07bd4d9663024d76482aa6a81e3584e558993a4ef26edfb46ed788c1f36044801b4ddfd73398aff36013f735bbd2451aa9ad99b431641562286d7d44ba448c1a8acdcd53006669b5d6dcd02f0aa9d197d102558296d1774dafa8cd7349f55c8278f3a5240d3455d697d056843a89fdf205673fc99cc7976f125a9fd72b7be1d6da3cfc0cda921fb3e9f69c13826f4102c9a0dff3810c5c747c0789816cd1ad8e0ce71f8fba31ecf32b62d86c18a0469d930dc7e473c4e7c9f918538b72f18da96fe30355a1c37c6de69355f7cf22ad9440545c3f84d702c7a85cc7a340c87e3e6957fe4c76484bd0e5092185622e06494667fd076e19e79e3e9cc55e0063981b17055151d6bad9b70978d741f473ccbad859a4e8dbb179bef556f90f6245aa4ab8479050616f859b84176e94a99c80b8254d7d470dee016537896961e70f299704d211c18ec4f5f394e84a4b0963539c54ad65b9e5b313f790a90bb639dcfd1089659f8aac595931622ed9717607f178f8639c7bab3959d96d15ae38dc542a19f17d4a54015665fed39e4c3ab8eb19d6537feeec3c0b38de6fa5ec86a129fc189e06d5a698c8eb8afef097a8db0627e9df4a50ae7c79064354ddec3450ccd3c0cb2cd3f54cf956a417982157bb0a8e6ff36a3bbb24a5f4d4a2cf8b1c11a0247bca6ec255774ca6947230037f64694d114ee7992415f4b96242295b463f90eb4eb50d47f9c5360566010c1df66a88443727b16b96b6223bcf94ce1a7eff7e56511ea6909eb4a3dd65c51beece510450c9fbf52784614ec912826210cc9f81a37fd00e5d93862f5fd35347e4d11973578b862ba27bd28f4a14ca285b7b195a76522c67051e7438d1a9ed25df966f6b3e98cb8cd8bda85a345f3e4aff55045a407601bd6881c4214bf41f614e0ca4f53dae56f38b5f607e8f2f6c1d66f291a15e896178d14c22f74f033678863dc5d433b986f8b0f809232779f2fd895e1ce3982880c812cfa0af3841d2a2ef7f8d6b9a957b8e980a86a9da949907399c9e93c88fe72921e05bcff67b148e1d615d2be26f3bd55a046065f298eb9e1157420782c28f9346cf4cd19ed12c423ac2d87de9050e44d90fe8c3fb4b74e3dd49a9648370ad193e723ef586fad8045ac5feef5ae7670b71a32c2cbf51a36e9a76bdcbff552d7a94ac8264c7800e7e4ea0bfe74d358381fc60cc96603a55fec3012f192f674fed19dba86acbde4f3571b2f2ddea1a3fcefcf7515b37c32cc8ef3eaf7dacdeee4b5cd6c48feb339d128f9cf47090ee63a501d4f1005491665a75ff9e3e2a83aff77298be1cfe7050a796a0bdbe95e15715cfdff768416fd4ed8fc28eee4d27f30e284fa37af6ad7aeb38c0d4e92f980a254ca004463546b807fdba0f9a93f9c0376686ae0e2f261e391fd71278085ed45c841ab23899dfcc1565f0382d8cff4dfbac167a3b6b2fc8a78e1bdb9f989ee293f6fde32099861e9b4090c08b36f362fd90e0d91e0e8e0da8b579b08af46a10cf821025ca1de8df96c7f56cee223951066e9ec410fb57f853bb688c81cc13927827139c1d1aaf3bb143bf640b824e2abfcf2b4c6144e1fde5f997939406561ed71508264378de30c7748542ef957c96e2a29c2acabd189f96a2caedf85055e78ffe413d35ca5dd1033c83ec46a5b973fcc230e7a3797026c1a9d4ca18d3da58892a83d46c3cac6cab2dad03ac5ad1e59165eac0ca6eca9a6dccc94dd7b8646e55b8931aae4fb2dffccfa08b95b66aec5efc26c6a3dfa32a23c6428fc9cc1c1411801f2552c3dcd84f3d95c96ff0501ae0d77b90ee9f44036a17a91cc71819cf6af1c313cce983674507dd8714fbe1b914cd2b168b3d7be617a39df9766b95b99ece75a643b3672251f263df124a813272c86ae14f9a750dd668e3fbbbd62246096857a50d20eaab3f05775de6f4a9f7bac4ed07b39acd08d5c3827f92e3ab99a22db968d6920ee0689afc38c30a7ddb3ac66ce3ec9057bcfda595431237db9c481d8b15fd71fc9e7f1506f640e4d5ec488649d203692796b3db2fbf187206726bebc574a6966a16ad915a2463b54adc289702116ed512c56156ac995b25a352d32ea3d9d89b8b05a8c7dd13d6b386592f256b899f1fb1ec52ff7bea7d036cd8ebb472b895aa9d57f8efa459db2322868d0a92e583e328a78b808d98dcd3e8d9a3916dcb9285995d6284a74943ace001f70c5ecdf01aa3f361eb4bc03011a162397b8f2c490939c0ace6e46347e93dd90024327ccd98cfbfe8af5bdc6a82be0d7dee89a1556c5561b055ec62961be6737d372600315c05423a7ac769b75142db1b19116850c4d80599caea2f103e38166d6b4dd9759dbd790725d55e834fde610c62835c6a1d1b0b80031ecc35338fb8bde1188390244ce8b63f8dadb7fb92f51e8d1697ddf93c6628da060e51df7bdf5bbaf50178f89b44e81071c897f546393438a193f3815eae1fe5b9876dc2c32e0788db3b4136d42b026d1d54a8d15eb87fa3e419f1dc78c1336f748ae81e603c3ff5eede95a12cbaa260a6bf4706890ac9e0a8d8f87df19738c6880cf97c4c329c049ac2fb671fe810b8fb4f16f4b586d8bc2309aa8cdd94e4054a5f61484227d7a7124be6c20039b96f591b847e913aa65af614bef805fd4c672018426ab9819a519deecf6b2ffd7385fae83f026d3ed9975ed77e411cda7abb683c1b950ec7d412410f0591563d85a7c88fbfba4d9079fba8bf1afbec9707371fd039000ca6b320c490f8a2ed4adf4949cac12b537168acaa7a0a6902420cd6ad3fb1043191bddebfd0684fa43780c7a5387f23876d2bbbceb6b5d65e0d29f5eaec421d50724d4a35f6cadd55e1d2b6a6e8dcd5a468df6a4596e30532d8624ddf94c7da9b4cd0d0e84771d9f2b29809c5af9b8b2c0a24b78178d61f1503b74347b116bcc623e183a7070d3c5790820d2a657b4a031dc4c6c682c9fa7447b7c9ea19cd8afd95e91305f9e7eaefd666b9b35966f94cb1ff75d7559141a70574e1e8fbf57860095fca598959603ab3fbdc1d8d4383bf0a632ea4e95f506bc18cfef50f99b352a679d9544f4120e717d6c6c77a311bc82b7acb1f50bff721ef09b1b673f7adef9fa92a21b411a0e7f87863293ee180435dad72026277b3df92bc932b908ee6ac37e5db6de00554805a76f124b053f475afa41353ebc20d38f27063fb1a3e5090fc9987abb37ca5e2c0e56395b75124680c58ceb4f991a47d014f1d5f6e7069f78210e442feb2bec4ff4417dc9e8a6a3bbd519a17e5dc2919e7298faf7757da0f2e939d38e50a4f75f3c003ed9ed571b11441aa39e5ace0dd9f52c732ba36affe301a8ef1c321b9480073819f51f15858cb1fb0e864b587366ada8ce618aa73e918fe964560c7fed89b8bdefda14deeb50509b73e5053fb52b7834d576957a54643f61a179a06ba9573db035120da1208fd5d8196c2a3b77760b52af20bb3c918373ad810b554d1a5f87da853f433a7ff4e44c74694bfe3f7718ca918399d712962feea2be54e856fda1ba13238c47174810877da0c1c215c59fadf65a0cb5f92afab05dfaa9340b7abad64a1a9e2c1ffc41d733e0b67341e433735a19f762ab855e24b32adc2e2886e1acdf7e2fde532691bd39693acbcd245e653a06b0b0bb30fa451836534676e5a9b48228c3d05d059c3b4015d69725189c29aefda03a6d8fa9d73224527fe18ad6c384a181ddbede87a82d80da1c3c2f8663ad6f568cd8ca39c6f2d75eae0d7f0007215d756308da3f8ac02a9e458837c77c7bb37c35746dfb70912aaee70d7d22986113afa8c1e6464846f7361dfd4a05b197cedf02e615eec0e06585b3a9b85dbb08b2f3b624aa12a1c5e01201f0ba2bf755529de628fd52bffe79554740544114de5b1348bfa6b27707ded29e6cb8cc6fc9867ec7d0858cdabc854a592c24b56129b67a8d262225ce31a0701ac001fcef1ef0e56ec298554a6913f25dc7284dd6dfa3f78870d9b25de86faa47b902395c1a91350fcbc750bd42594a51ce535cfc10cbdb393d246b89a111196446300c8815548d563893c7fc4e5740e45e30e88407860c3412b6c94fa807236538737af24649e00d9f65ee807c233bdc70f7e7af79d34b0e13dd8b8c44b823fb4fb10d030d47ce07734955ea7995fb96c98e6bf37906dba529c2527efd8ab5b6e155ba9014a6b7743457d0109cf91ef3ce4e72e00dd20128ee540936197170192d3c39bfbddec9bbc812cff40755881236f9c62813c77d49d283ec71e5ac6e31221a7effe214370eb72426a809cb38b7c23a85f9e73bb90d2be946a960fe71bdf5cf5ef77285458fd626a94ba6c15808d52175d385f4223650da6efbd3f40ccb5e6b9a0c7aec3ee132f3b9b12810317e27536af7e6ca11d53f5368662886e81c8adc6663549d39c2a99c2d1f31cd4f83e6d4118b369b53cb07bb54a742667e2cfe68cf9df83b12efdf1b495bbe07348b09a3ba2f826c4acfa721375ee44b4fb234e806ab329a4d61a4cd4c9d40f31c564b70f6efc18372c962347c6ab13584b62deba5f13bb33d62d1f4ea3d64f200fdfd0cf28edd66be4b82951d0944d1613d7b8a58e262edc7491b85d4a76864bcaad06eed8d14a9d611471f2a7d5d8db7fcdb8491f1ed96c9a03836a46fa1e58cc2d7260b6b7e35ef1686706012937e560ba58a03eff4467de417a6ea7631b6a089c44287d58f874a3910a8871cfdc8ceed6856ac872676aa086691f1c5739963319a0fa17868755de9fe47beebcd0112751d403b6ca2ad9ecd2c647fc8de76e4f2ab9b87ed5bc98adab3b2522315cc03c9c62d995ecee2835980dbceb885cb2d32911b7a41793d6822e8e4596a94c1d49466a49f80606c9d2f743462e747edf869211f0e084cd3aad6a46fd4bbbcf6097aaca0e0ba67aed0175a6ec9af5aa1619f218b0006cdaac05ad8aaad9781c6be34bd561a99d1eda5b79a516b234f4b0f95ffd3f5318b7453ce81c8f7e6bfcd0a7f3076a72ce9d04e674f6b88f865d8376de02bf3b1bcb415c902e233dbe899ff8426f76ff76e2dd56350bf80c08d4a224ebe6559cdf976f18d94fc73ed5098d25fa154b4893fe35766146ca41af1e0db4d36b9e5f25b4ee50ac510692a2e165932042ea2518a1be3452fededa5c226500a657e833593a8f6577a3de7ae43e79638761c51eec611b5c2164f26a53ed82b437cb2f8efb5d9c3f45b76ec1fe9ce39c15ce06fe1116ac3f3455610cf8f8bfabcc89184884eba9a8153c62c7977554df47c0b30cd3f3dcfd247ff5f8f863ca7add89a7f23f52c1ccb66764aed7e74550c1a555296effe79ba982cca765318b721f18b6cb8fce41ebbc3e4f61fb660bb43860a80c0032043b802fbe37e538c497248ae413fa665e6cc1f43e54ceed46ff1fba6ed2eaf2178d8a25d4085a00bdd175830d79908211aa166a9952716fc28da5444cb2f747ded4cd7f47c4c48be73cdfcdb7cc21e99f1e619ff7b8601dc4285ad9cf8e75158f37fe1bf72fdb8630436456ee1e2a3b61d06a35b2c1f39741132dbdc16f2b15a093d91a1490ef755927e5451e3aacc1368567ff65adaba9e9a812154662ec9ba75691c7ca50587a665a5bf517b46709782f5b6404f400d01a1068f6baa2d9c574c8b8e41262602dbf569667d01872356d9b80e3b090c2181ab66d785943bdabd47aed7c900b0594907e770207eb5b01bd651db4992144c3e0ba18002a0f1f204dafe366082a27719a58ceea985f77f0713a1523feea4b98007123fedcc9a19c08ad12feba8806aaf693d80e205eae1b1be911f61b17d0a78b5e112a78835e7c2a3330bbef875e699530e5bd38a9b674c355c5e4dbb5c2d7b7fcbb398048028fb355a12c203ffb42e9d95b141d6b74b3419c586505b55486b38559f8e393b3d3634e528e8e3bf34edd7f9cd8e4163f0e0ec71b40502556b7ecc9a3410ae0b354a14159806f5604a1930d8b2a9c14aae60ab367be05cc42a6e75440966c654eb008cf274694c1dbdec9a4d56a74554d7edad1ca2de5541426e98bbdbae3b98ed67e746133c07576c19ef225680632a3fca966d3fdca6a675536bb6d1e3a23d6178d78595425dc7ff962f829642345e7e66428d948d5db394109d3c29292ee6ab31d9a26440268091987a8d9e9d8e2cfe055d75a6bda5805f52fbc70ef1b2c565ca4daa8b7edf9016b7869286d2e48354afd64944003b37e2bd11c8a7b8e9b834c0e7dc35f39d82450433d98a19134bdbcb1069e9c3eff2250c23e64c342697bf7e75c0a0ac15858010254a6161a2f1295174c57587b60e6a3134dec9a430949a42267461968ac6bd99a74742db075beaa123aaf7070286513746c807b3cfd677e0f5cc96650d25deac12753fab286f058991700dfe94ab9acdbbd91513b7cf560dec399c0d11432b1916ece5168b5391f2a5984f468163b9af98ce319cd6f7e41a9ae3bdbb2844020dfc43a7ca2c87764ef38cc5ac7f97db3e65c5cbfca48ad6ff6549faefece58d29cb8438012759e5ae63035e9e4d697e0dc29c9fa27c483b63d6ff83ae054407b5b12b6e8a09b8f2e25131ec017bcc13063672fa10d396b334f7850a38d3d53feb4c226ac422bf28a0989950a047de2d952e7918d8f42c0c1dfda1f4af265fef5df493757dd129ba7d94e4128dafac10360cf05ff665abc0e5cab40fc3104112dd14aefb60256d669644f4129b07e7c64d3cac5d00758598d1f0d2dd740b3eb50dde7f8da2628daa1f79ff02402486ffdfa880b6d012c3336be065c491568fae860e6e198fb48d66397ec20691d0e60d2efad98207e2a80f7c78b91fd862aa54458d9f3df5e3bf817acffce1fe76305f9cff4b1a6223a14c19cc8f8334e6f9363c164c4bf15c04e376b8ecf0d30dc5e63c1b5485dbf9cf208bb6eabe4aa2e9fe539f3d1d4d3336f6f31ac4753c4cbf36871ed6df517ce7378e84e03f0c9875c7f5066834c02dd12be01cd65bfdc2b11b4891906484366d57c5c2fdfe1e4913d81164b5626e020054ca5aa8247cc058d88b1bc09da886eee92c1021b5783df1e156da141c48ed99f3fbe8e12357fd6e0d3f5c80ad8e3db65b8a8572f1eec02351797e8d6040c96b66aca7760b5bd66a5b945a9270f3c8957b978979744e907a83285b4d4e76d85781bc43b8ff777b4f730c4166cc35d21979ff5b93425a7a8de9b33a2ffff968dbe7453083f19a6a087425148ef970bcec84cf535d0e2bc75fce814090dd5ffc1625c5509c331ba3e5ed6e138d63317347ba360f1d79603cfe81801fd8bce680296b2562578eb03102ef827d0ea808f2593b0efc8a86ce719a0ab2bf23d65d07738928c500d94660fa20673da49b69fd02477111b645c37b1eb9bd06a2015ebc8db5211a1c558daa2cf0bfbb8a0e2ad2a81bfb9fe5f46c0d65191c4e6e731e619cea90a90c6f8b7e63e92485117f40a418cd217b640bcc82620b90f59f2b767ea3d231ddfc53bd2cf4f492b93c7f3aa2585cffb2b8683bfeb33ca7e3db82f4bc3fd57f9779221586f4b799e710c3158c15216236bb7a53859fb5418eea9deaba50425b305ef97e693324c4899f599e0264fc6222d231b75da986f1aa7edddeb2dc7f11a1942b1c901bb5a35afd317b974f7bfe6decdeb12044fbd9b26a93e40c62265a7e75c98114723d82ca922ba2c1f69010dbefae8e0de736d3b2d4065f3ac07c3b841665578e5cdddf4b84aad4a6b10b72e8d973b473766560b41a8d94bb0f4e60c443127145c96510b81efc72b75b058c8a8e968d0d41425239e1929c7bea8932ba21404dd26ed7897e53cb18605f4a13c26394a7301aaaf7a73f84d6d3e17cc285cc29b1035e9eb689f763f812b87afbf460d616b69b4bbd50b808f18836cbe3ea6f75d22850b2ec57b19d39605eca763dafb80a1c9597967c61d6d91497df8d6b673f2219e15b11491b20d11074d967dec7fc04f5764a9d3c2a34bcd3682ad9beb1745710f626a281b411b05f35b9a269c17b7e1d96e3dff1a274a65bb4ee682a58e66d2de86f92969e09a803dcf50b5bd0c587699da4cd11405bf0a6f024ababc23650fe6c234d017139d1503622617abedc61d1c6469be17c031900ad5721edebe353ece6701b146f8e361a6bcf0dbe762fd13e02419c55eb3ded47310e0180ea4e6c11639f58e7be7226a8f28fb53901ad305c904260c7cb4d52a33903f4d507d8ed90a2f18bba119d3a3689d820938f37b0f8fb9828f2354d26cfe173a20590f1f2f3e2aefca2ad7b58cb43e4f70ea8ab8d04d2ea93e3ee8d16eb9d932c7723b61020ad814a82d1a175f3cf646846ebb70bbc1a616e6b28f5bf717c9e3b9b9c69fcef8b7eb99f271746b18dfccb945d4202aae9f9af71c4e38ec66df80b835df5940b65bd875aca73c426e5a3a9aa5a5ae293617f107f16d7c7464017cd8eb97a8b3f43aa60380d05f32b2e7736c1154e9f6fd22cb9990d2b4e5c7b6ea92f66bab4f02dbe1546d8961c3b83632d9db31f30427f8a68e393f90e071219064d0220c6dbbcd1679e15dcad2dccb382bd19684f55d7e2c856631af1d83a1f1ebb37f7d18d768101df51f633b07b5f4f95fafca1f3735b86e59857b5b67bba0a0c86ea86fbee172ba484ddecd6bc83ba66f5d097f30c2a6c44d61d308b30fc4630f46a8193313a1b5e1129e92a0c1658111fb0a6870b3c2bd61a605ca0f1706e0639879898b7a2edec4d26becfd74b5d1a84961a55d95895a1ae63077aa71513842be7123b1cca68ce02858c88261731b77c059f939566021ef0cf336b8eb840584e4724d41da5b69cfbf223898f12d09d99003f577c8bd2b07bc9633d8e59e210ac13c8f68fd934280997b9724629475f60a286d35e8ae40bef2c770062475e119595e572dc5c67f5af1e50a348bc296274942910616447855d88d42d3ccc5575ff3963a219b22d203de8b434fc00ced3eba497e73c30e1a83976f11d6b8dad4d57886ed0082816dea7199c9412e93a0655aa6c880cf31bf4e759641bac67aadd3a835e395312791b865fe84b49300656ad5fd1d469c411233f01304d60f3ffad3563f7e7ef1316003a4999c437fbcf4a35c23f4d87de9764d361bc0e2822793fa24e06f6c4a01dd6d4eb22814c1a415eb072d5fe327a5f0f5b9936b95f13dcde280ce0ed2ecc3a641975657eaf32556e658c161c13d357973b7cd2b41911edbe97ed0bd46012df8f66f06222c1aba09d88a0a13876ec4a419c61b5d98dd33041d5fcc158998c30befc0ed196ceaf0a4f288e25730eaaf217a698ccb85b48a956043350bbb82ee5930c8c6d6d226e7adeb50954605b8ce09549bc180791c481fe6c838775f91c60d0a0a92ed160b313098155ef314ec1e2d4db5e57456c54b7e7d0e02aa92503ad927476993f31bf189927567103877ea4be4e7280b4575a12fbbe06628923fef44d920e855d5d1ba7a7a9a562b14525a42584033bac8e586247686c320bedaa2154b9ce017829f390fbb476a0e11f0f5281c1957f05b2f26a6435ffa1b21853a87914f78556dbff8067ac9e40f8f61781cf0b525e89f6af0afcc0b1333e28cd0ddf87351309ce81f44efb8be871ab886e821916dcce84be19302ae9176e06f32e45564987c05b987c0823c1b2f5194410b90c1cb8b55acb1f82ef52b9bc7fed9218ee3807b20c65a32bb0ebdb54b1f1f04594d502f83d942715d71f1557e172c07ca7e64cd85ca402c010f8f9e50ff1cb8042f9430ba29e303324878a08538c818f6bf18dfa21b869988c75b64b6c29b33534e67847d67deba043370d874c28cffae28109b6adb7f76bf8a1ea6df2c4bcc80751d570c6a9d723d67f4e38ee61ce5f2c46086afa5799940ae14d9de5a25ed14ed3cbddf03f3a0cf9842d693fe3fa852ac9a18f82ebc8f170f66641df10f01b823d6ac5629ff9ec4761067b543ede707d6527b74a50053839f348f5c583a9e17e0dce4b7c99d9c4df15430a9ee02e2e6f411b30c357db7705f7e7cfe24fc774d1add565504be0541eda10c4be0acdf968688775597cf60179b03c8267573d4ac4b6c4bbafa571d8d15d56d08cd734fc5cdd0d89b1b35ad4419bedb751eda294ce4e29f6b776b594d6a87e300f78fbfe3a58db19cdd4523d2ad440977ef5c3b0438886efb09f98545b97cf39a622199e7dce7777e0966bddda529bac6842d90816e8e36ab782efcab0d306110427c5a03357a50e4026334efb917f44c9dd159c269a58b3eb27fce944099da608fdf77b0bd8e14443c2293de2ee7815508a824734369da6af8a060dce4bafcc4f1ba4abbab9755421c2f90ec36dcbd07bad3ebfec7226da430c7a0edeb5730187f841711492f160d3d06a0cf5dfe2b0d005aaf51748351c8c80fc7e8c77d566395593ac8402367474e2a17faced46397b5d5a5566bff283c2fb09880e1fe831e68b7a44e49ca31e8123463626696f5d96fc335b822820eeb0a7c1852021016d95471ab08a73cd83236a11e3a4f9e8280510ffcd47b54cf4c95183547f34bbd827fd60c4afe2acbc0692d76b52702dc6190f26f9115a8953011df543c9ff20cf71f343b0b6bc8641878b3c4e536944709b538adaf62610a4bde6488bd6fd3fc8780f2429bc04d17c2bb8eff2f2ce5300b69cac7b1f566aa1c1ecb0474af8c81592dd118f7c1f35281d863c8d3d896384b9f456886d98408f97335f31936c2d767af3654d5049a14f12199be149063972ebca095e096453d551471b2f83b25be8ab408c4c7a511d150e269e23c67b5e063f52934ec07c1f6dc0788bf1c2060fad16a52d62d03b2908187c550f824407af4c4dffb5dd91352d399ba682bdf2425b97a8805c79947a6f3f2e2b5022132315a70e8e7e0054e9ac46219c5f5c8793a556d48f1e03148c0767214d7389f2e631c0884a7a3310fe7ddd602e7c27120eebfe8d7d0e956a0f35dcc89d605ae7de3cbb8e9afe3d038f7fc6284d88b9d2a40496577dc6af529ef399717b3fa738f7823efb1a8008e11841361ba8a00ba638be103aa0dc2f6a969ccd79a81a793b71234ddd4a1a1c2402eb59ee47e9a2c8c8e86f615290205866572efd502d99d30c3e597eb8b00d0d52b74d909521b4ae23220f14425c74d2436cf6b48761c24b67af022f3c7ac4a62fcb97b30f6ad555ba9387628c725e2d37a4aad2ef3918b40e8d0f29fe22c8085dcc5e434d3a5e4e335bdfb492d54889cd33630506e35c31c398fb9485e9ec71c9c8f2503bd6f953db80eb6bd62ff50b38422cc028cbfcf3a17d798e8788f0b257236b9e087e51d4642ced51f176029ae6d9d3c46ae6ed70e23faae753628493983d5ebfad1c6d23faf2650be5e1da75050e809d7d7bf5617678f1a02d0993f2f62d555e71616fc59115aeb2f511383d10f2adb4ab286492b235fd676bb4b3997d999a377ed6c09b19136ec08341f84112cfa8f3c6481eeceee9971c97c4fe8054d7d5155a5e36416e46eb1b52890c3df18ee58cb28f56d35e50403b78ceb2b1950394f698f2ef9f8afbce9e344b80bdb8badb36833194f45e50f601253d683dc947ff2a23afb71c93e96d064ba4a5e4c6135fd607e7e64d87bde0410e9c5041edb3036b800e5ce01471259da4c8f031e45911a5bdf750ea1eaffec3753ba4e6b9ee11a6feaefd8cee5e27b127cc3fdb7a43b72e7c6d74fc6231054686c9b948fc214f5b8f384ded5db89b9c629a495e1f6460c2ee07d13cd1347dc3cba106f5d51846cdf0207ada3e30635493308463088f79474c1ed43838207dc81c3526822cce16041e35f7807a2d0c6ff93f5561632469856d9b84dff1ffbdcd113951a7b4e01379ff1cc55efa156401ffa42a74e2f563235100a2c9e78dc92e955e1a2b5a006a397d2a45f306fc33bb19ffb56250c1552102bfd94f4dc38be73aa61f037c6469afe41fc2d26393c95a0a0971855d61dff5e91f378f3fae437520e53109f6e23b85ea1b5b9c934e468a6a79426e8108a73162924fb73e7512b6168118d9b06274ae95c46cce4eea67ae37343d9c7f723bc00ca84fc41fd7e29d0865d2d651304c9e336cc1a863f666fb543717930a70179c80c3375257454e211d68693378d7529977e19eab009c56f4a6209ac6fb7c4e3265b7a3680cb2777b0a3d86dae68eff07585cfdb5c8d60f9420366d5d3f0c8ef7a416b93d55a9f46c63c88b18ff022b7b6df61b553b5173679e07eadaf8471c350ea580e12703875c69bc63e30beead3cd60f3858d4c55d22b99ccc86a4fdb5f880db1a58af3b8f6b393494ae73afb96af920afff58f8b2525b898c3e7cc52b3a136e1bfc47fdebadb289286dacb57c6461db5f11e28b648ff7cb7f70e4d03407f359aa009924cd786806f7e46125c2363b57dec2cb3e0f15b11cf0a0eb8bd5328696f9ef2f621decc0906f309bb5ed2814fb49e0002248c31234d09fe07124c82e98b5a86d2d7a4f581d9a49ab43f3a47c1b8e5e72d66389207b8f1498232b7467c1beb83c54d3d55f00b8f6912e0bd1f31ac7bb8a27f5f8da8fa86bdac6607b9d9054f7635b7f17733702ebc8af81be474ec35d2f5747a9364629de2c1973e6476283b289cb398e34dbb07ce870111e2825c943e5f5721bf7659632a69a528e854ddea82e6e5e3542d831dfc65273606e0603d36d61e3ea6ba6df57adef2d2e49f9701b2afc275949177b5267698a779cf33a0ef390e32fe6be9b82879113abfc10009937117c8feda18f57381cafbce41610ae9dbbf2302703fe9197ec49cb6987b863e3a9ba3c6304e5ccd6e34c59fa8d492f21ab86f32e882c110fde1f5cfc6800e3b78d3dbc105ea5ea587ecf40a6d31eca50e8b22d095ed3a7224441b1ee7d2c561122a6d79ee9052cc97cb3031e13d6dd13957762f625017f7cf08ea21b3c48497b9f7f16fd637ed2257b635418868ab18d8d28688ec7be98adab46ba52b824491263c157a6b9c279ceb7d2eb2f75b9ec136f4d141c0077091eac1dbc35dce94d77de2f5f1c2d226333a5300517de028732a95f63a43b626a856b108033cd15dcb056231d65de1d331d9496375f46caf0dcb5e7315eaa05e5d09ebf00b4ab62ac0cccf72149a3204848ba09cfa63f0c98b0140d33f91f2b9fdd62712a73fd5a0f57686ece3c030f45c103a1eb14e436879c114f6644cee02eb89524fcc1c4c02dc1f6bfc6d106d33d36f0a9228d349060cccd51245eb0600661d26e635e924ad5f3d37ac111eaf3a037f4b53174e7b4c264374270ce73b26de915e2193e0a34732893a9b367fe2ae3af4a2518e8015b5552bb773e5b82bf6a816ba21ebe43fd7a45b5e2ca25940da2a243779c89c2884eadae2cceccf9f42f756e0a325d08416175abaeb552cc4ead198f2d5edcb6ba19fea093fa0edbbdee07c01efbda6f5bfead394d7946bd4c8a27fb5cb5d8c1b0280cbb966df0b156bfed0ffdd48323a27bb084f317675806e2c0a9710abe0c5fc4b0f01723ca33f74df9cafe8d3551dbcfa362771bac3c303244e6f204fde8fdf04abfd768acf014ff5eed1c52208c2955533090a093edc9a2541ce7fe5a06d81f2343f537f7651f7c65cd03e297fa7f3c136dd575759934ea41892bcb1719fd13c06db814b8a1671fa0ac0deab055aff3e447ab8d84f76f076c46985f9dc91ec855c8f23f1d019619163d1571fa0c9e98e99284ee25fb345ae4672b3aeb282c0983d51708c4798308a52a68b0c50c0e8114a4fe1e349bc0681798fb701f49ac06fb87307ae82d5d8d07377efffc667fcd3d5d5e3bae3f902be3e4ecd97b519c77d93d39f2af42ef4323065175209f6a913c6094935a0e7311b5e798b490aa2f1c82e552c86af3c7bb4b17f24d05551015dfd3aa04e9d03e1fd4eb0d3c528af688959ca68df7d2794a6f2ee63bd2c90df4eee8e3f0e2a1c0bb6ace1304b24df206db59b0b5a23ce8e0c18c07c5cbf437a4858935b6dac93af734fb7b24260ea5930f49ee35261c4f14cbfa961d577bd0aba34ece475a65a355dd87b50dd42abbc66c8e324666338d95ebc2064e4ffd48887d92fc84b2cd36a7fb62f5f232055cd064c47bf1db5dbc151ea6c62ed27098d689c65581f32547e41824023e6c9ba474f24be51dc71b02b29c7cc73fafc4f3bc3180070d661af387395ffa990e110d457d2a34992b722221aeb93016425b2a495591b90dda3f56eea70e60b8ed9dd9a6fecf9ee5404e77940aef5e85a49f2ea1a4225768c01ec3b6c2d332fdb8e25be8cac3a33d8b88ccc32a3842e10e5a4bf25fdc7b92d35a639ef61b11841d6af40f12ef1295372451a1c1f247e9999211a5e40a8f8cb8331a444d05362b756a68a6ca463693c23e3d5c8c7ce8289265a0c60497ef08f5f6aebb55ba7ef97e6dace9be93dbbc2be15fc29265d21faf83c0cfdd4c9aebf331c81614ab505b8faaddc39406a0daceae4adf00d4e462c4c32e2300f6424db64f262c1bda798e89dbf4676bf82968f774646e2dc8d0d339fcd3fc6976dca403fe67eadd9084293e980098315695f5c15ebdd8f9c1c90dd245df70165889ec090d9d6d7625643949769a7171e22c3ed897497c65f99e7a4f2e42ed9b3a0ae3e02ea6ad1b5c71cb5dc15f224c6b358852ec8293f3837e4cfc11180129a74a062f84bef63666e50ca78c7e49e17ca437afc3f82838fddbb6b50f28ad021aa2de64ab1aa1b7e6c2fddc8916eb98461aeb3f2132f7c52e31ea76fa0c362951d21e0d82ed074af569ca48878d102c3cf75026b1fcba96ac39937145367e634a153ac29431678eb1a17f177fce99bed4bfff2c2b8d02a32154ba76b173b754ffaff66b036e00e721777ea8c4653432772680e8033b656ff8133167477bb92f3b559860b254d4dbd9c67a418b84b66857373eb85782d98e86119e67577679e17f54334be825bc6e43a7fa0f99e8ab39461c1f12e5befe8851d484c69a64632e1f93b91c487f8a5ebd1fa9b644c7a13767ceae5a809dd23b8335d2076b16daf5a12ff0ac2e3d4667b0bb112f5ed2d114403c9281c60ad2f2dadc22e6686e9647f3a371757cf603ab1259e84fabfaeece095e9cf1d3c25cb9538c7973114deee70cb2ffe3ab69bf1a2e165222a366b0598889edfffdbb12f2c93c3e0dbf69e881d17153c756b01bc73321dd66b6f341d35e70ee778af39ecbf454388138f32db6eaa3adf86dba11546e91ff22001018f4687d97efa430ffc340069521a0a79f0e35cff02fec21038ab3ad09de810b5eae9eaa0f1895e40bdec2c3d0b1278b1303773ff95414ac29ad8ce862a6c1e0bca736dbaafbefef23966585ce715b7a92fccb61ac304edd0d2c5c0001090bdf8d3f48246b0477f18e2bb8acde35b09e81970fba973eec7ceaf512461d0e38d893e1df55566e8034b376209beb2bb01629c7765a7e5c19482752070d583602fa2be92fc77681ab3033a81f127e59f645e60c24881ec14565078575b59ac3ecde79e16fd184aeaa0e1dc467bfac66a96675abe4165d3b574560ea49dbde3620b985d4584608ed3978ba3044273bdce4f0ac809ab27b66120531e8cefd16b4c878669dbf9bb4802c1dc2bd5628db01b79d1d08fc23479a0348f8cdd4a8904044b1346237ca7b9ce2d34cce3a298733283c5e6dad7d4877c2f3e3d76c9efdded644ddc6724c2a5a5b9ecbf45783bccaed1715c64e50f148cc418c2d398aa0a68f94aa20ab76746054345fd90d4e19d264fb69221a70dc75fcdf6baf9caeba3cdec8970d9a9c071ff758b4e8194e530b16600bda6234e0a21da54d3014b1a9d98ae78a8d9aafb06d3a033f762bc4071fa5731e7a379882718634c02d33a0a97f996ef3ab9e03fe9816b0fa1d539858b83ba526dc4a431facb73096060e65c8c1b5cad7562972b8226657e63f6807a84680bb71383d94ce1414f62763b288dcbb867bd37fa28d0c6c94e912af921373a9f270c8d3f3a501b448099ad6279bac40a896cd716c4e8e3d1a6b4fb5c23c64fdcd7fc6cd41787f9a405dd7e3662a098ca521abc316e41eb72381030c34d4821247234d94fbbe1d15bb3f0a05467049f22f563a83dc8e320aa4ff698f0c011b30ff4748f6e94e076c4862106b4d41c228fa916b2b250ae31e14ae85dcef2d2b45f5089b88456d211ebad512412c9fa8a97682d7ce9a83c5b50500586e962ae3945e514080a4e2904a1d92107667a867c2c1d0a56b3fc9e1ae5d9ee6e6e3ba30792fcb9dc858dabdb667410577f0f8c865117ede11fd0bc7ab535f3e328990769c9b37308eb5ed4b6e080c6251be415924056c12f34042ae7c4539c3e521a4f5f838c99001979ee4178f8b7f7983411d188d257028c73e87a05be4856fd82a8d83c63a629c7fea0631c2c051a277dbd345fdea5420f2613d93b54a32929e821351c13ddc2854f0c045eb31df8de7fbbb274871ecdcd2c3de248aebdf0b15fa604dd4cf181843d488a35b3c3dc056dcd2e2bd351c7760f8868499df072dd33737897b7edee7cab65dcb279e3784f260f353c4167d06b68b5039178795586e0ad9562377c7672845bd064e2804e3c90ca3bdb02335fcbbc9a82563adf6fa21e499e2a938598bcaca3b3b6654a6cfa2aaa935f32767d8168026b302baa76a44d3532f9f5905ade04d965c248af2d5da21b1d13d7b5635327773c57f402e56098bf7d0a188b06453027374608bd7e4279c56f8da2e2f086f02dea21a29aa6177d6df09eb6fc68b9273736fc44fc3e993a0bf334322fbf87e9dd5f9b972981f3ce68cf91a6b9b53385858ebcedc0d5d169ff9943b54fb519f138c18bb203a749147b7668f8430e23bff988a2eab1880ca035925f205b01f2c9175d3a2acb399084d2170dc8e87345b4d18edd123fa6e2ed366b3290e874c9217cd93ee05f1a9011d1de0e86b8787992006e1b4335801fb8345117fd3cd889a424f73d79b5ddef116ce8576c81b468913249f8e0ee50ec3fd18526c8cf97119bf62ed0c0019cfd16b514d0616f16f73888885e923f78157dd2afc974ffc471d37d680cee795e88441ae2c8777323983be847bc3425ea2b0262122ab7f263fa69b9e92d650b85ca4411eb7dbf1f10688d9dad7d7a04004ee9e920b85ee5062a8d8e96a9e2b090fa5365db8b7320cd65abd9f83e407b660261729690d2ec0eb3a70619b7e1bdd5018d54f3ad3e49f19d635a6d67ec33638a4e922346fdd5ad93822ea1732c816554316ae1a097a34ce6b5287bd8700205302f0ccf20b652e2a7d25547c39995734d4301cd6e2090179b126c86a6b26027213d8130c56b23168e10be517f5ec654777463e37cce31786d21319e6e64cbe7023c9dafcfc54e2e9bab55f84d5ca7769a093f105f416f3199ea4517339ade7f7a1dc3d69c93a080aa1832d260a0e7d8fcde1de08a580d8723d0caa86a5c422177eef6a6f99468a545e5c39c22f08bdd01fbb8ef24a52c85854fdabc9f4a44e44568f892d16235850240c066b00d362e1ea6bc54d57e2c97522f0ec0ba96731e0e505be8ada8f0df536fbfc8ed2beb77976b2e3c2ac8b9ff7bc0745811c499ebffa98db16f0e5bcfebef4ddd27f9f096aa0dee6cb4a975ef971e72b79ded23333dc70575758931a38177285f0d41da9e275460bb711e1c433f99bd57108ccfea9e9faf728d1b880c1917bcd3ea86cb31347ca180c8332e64fd25de77b84578d6ee912579208fb720cd870b4c8e63c7296eee45514fdd9470faa1dbe984271231979e7ed95f21aed5b10bb32b4b200ed7bf89afba66264bde456c5cafb2ac51300808d818ab3539a77b4b26ce42630d078c0e9630012301aa77ad79533b55b662ed92314f44ea4ce6b9a005add7780a7d4c0310b09ee80df1b668b88b44589ccccd34780230ce7a57e465167cc5d198f6c1a9c68c75f9a6bdc9aa2de9de4b4d7b8fb94d9f3195d644b195b2072ba21cff68989d00b586f4dda6be8c1a26893d4da78daf672083d47fa15523768ddb1766df308775f33e059e8e0b7193aebaec861367ca5387856e0ba15ea7cb90c8e52202b9431fecc867572b8acf5b4c10338e3909b821a67011872a68fed2d170284dee09def6e3659deeac35882a16dbbc5cdafdb1a8f6183a50f1aeedcd7621a9f2a63a6280255add0a95f0ccee5c9d33ff76a4da524987411eaed174ca17df1faeaf8f7936b3c5ded6d9090ed90acb1b503cd9c79107fe6a5ead6f207ac6685b0c3ce7e3663ffc7d3f5f7a935f5430f5f14bdab5f045a955be7c7582a32514843c3c329d65054d2bdf74ad54dea2fe77ec259e31d2043ad084e753023324a741431cfae32b453be1f60ab010e17782d5c87168f4deb372afa646a2800bac41cf5a1cd807b8a64e212c79b057d2712bb2e9060490f703e7d2caf59ec4ff55986b6a468c04eb497b3363365d79f2a479e7f78c574ef670bb51ada2b8fb2de7cc2aacd70d5c171e02e8936c497b47c914d55bf7d5aef21bf0def2f681ee50647ec8ef075679e726126d579d2220136dcb98d7d5fa7e6d254510f48eb9f470b85a4345f6464cf6121886a58b20af831526742335f6f00f927b237cff902b03af14dd836c1f08974ddfcb0aa9c599c058d5684bd723801636e6a649d0309efe8ca8338ebc924fffdad05d73505081ab725ce5daf0ce6f982180420cad51a5e3259c0291a71d069bd5dd900fb80832da15996b5db6abd1a792638e1cae5464c1d4db355c8262cb42edac6d28d4bca75e6b788308cf9bfee7b2dbdabeca4e182c38bdfc9e750a11e1cb874031880a5ce788d696285ef79f6e555b9e6993cc6a44139f6b2bb896b1c3b190e768c98607d64f0869a2c3a9241ffcc68ec763d8189e9c0583c4607fb9a010d6f63b29dc49977fd552f369919c1c13026c7963bbe3eb124697a21e1d369910189d8a93ae581765314012b3bdf87215489c3f7db486fa1e76b29fc7d2655dc64a85a1216a5a6aa183bec13d28cc248b3a7c99a76c7ef305ece626818f4c22a1b542ecf1c29a43ae3444aa8fd08bb29cc45527dcb1771dc0272da6ca627172bd156df3897a0af118edaeb30c8759b89db5c2f9b01adad2cb17cfbba528a5183238a7fd691909534cc204e8e0e1b8927eff35010976dd008ace2fe1943dfce26b47fc785f7ab240eee0619f40bcf2f69a02f5634035f8e09aab19e649f6bee3fdaaa10735fe794654078fb623def7112bfd438c0eea0716e63340f8622fff0901dcb876389327c1179c319aa4fbe120e5702edc114d5558cd0ba5bfde893b0aa0c9627b3e640b95bab49319c681e3b00b05f5033a30a80f35d0840f651bc6b75b854f7c82b66dec39127c1a8d52e3af5ac95b72dd030dbbeb725048480a9e53753ea320f2dad8d932100df6d1548858f5b6c64e9d27ffd2d53a40b6add16abf2f1c7dd9cd0ca1056ab80b9117fb143421e95d5ec34bd1658eb4e77f2888da2d013c93969312208573c3b6db21097760f7e35ab459c304309071ae3e3b2a35f9622b473d06f06a3b5b0fbbedbeeae18312fea25e561545e4fde77d2a630994c2f0543218fcfbfb786c98ed39f400bfab2426f6907f964c95e98e6157fef85e7ae98d7c3f2622d61aebf5d1612c54c4c29923c5a1b8bb6fb9daf5ab2f21adfb00060dda08e947018693c6d7e614a55221f125e263ed84588d04e6118f7c791f5170a9700e07518821c291045582ad933e4c784c173793ad567f529d8cb8b4469a26a157a07afd41ab0ba9cd1c92835223e3532930d0ecb392c1d1bc7b04317a8779dfa39dac30a3b9d135dcde5daeab29767988fee6ab49c4268f55473e6784ae35f3dcdcf6bbc6e29600f2a372c5ba28b3f37ff1699e40184d7ddfef8fd26b12cc29ed2b8089b979f3c806727d60de394f0651ac68db336ff0fb7d6164228f357d1f223aa1760039a8154884f3d0d856b327272e492555944eb60fc1430dcefe0c3f9174f32c7759a3c04a6e0c968facb4e71b29c8495962e0edb7337d26435d408c0f9224d2e13687f635423252335b7bdbee8573cda0d88cc025f86460908fac6137f72795cd88596d61718fed314f686f181e9b4f61f9e5f457c904daba1e0367659b6f556562d4fb56850234f9da75a8fd4450042c8ff85e9d6baeca2dbcb115dde15b577ae6d8839e4d6a0675eec36748c664ab2369db9b58ac64a9f2a5a74401911fa6d632d22e110cac6667f671e778672379f1e69b3ce0afdbfa3839e1bfa52d32617c22ef100023f8d7ebe2c98343d7ff60aec95d77ec23c28d844a5878240e30e6323f76c23aac5054a98c4b46c950fa04c39465a118fb58c68d58675c2d96b2a421e6285738943dca30dc405ee41fbd8408d77c19745827ae6ddb634618986bdeb503f6da15a0586b030dea2529c6bc611b95c07072d9d7496b1783e7c68fa77b6caa174a05f8ae8643ce79a5b103284ce43615b0d991e64b0fa9b507f012f5ea535e1e8332c81a11a3ca5b782c40621ba387b3629b4cb374e33582e6ad5512ed5290b923cebb154677af15196e34bb897029c497b4c35806730eb248749589f5e5809c7383226f46b9f4d64974b559fb2f6f2b93a9b168fd4e268e6a85fd6ad4f889eab389d43a6ef8c827c83c7cfc38b81c11d2fa4316b0cef69f85a5a73ec8b8d3bf337a3732d4cb694dc30053d29528ab562466061c22d94962f9d4664ca1528d02cb9dbe55ac51cf5cb3bc867143e6a116ac4c2c3bbb984f012f6c19a0fbce3486a3a9e6b69d7119b73c2cb7944b79405225533ca21d191318e7011f44e05e081e6e33c2cf7cb8eac9edb5661c031bdec65685f0d773a9ae498d5c1a60688ead181a531a7488dfcb18b11e9201c189a85a8f5f0b2f2df770fdef9469d7d6e800f20ded149fa75baca15e29b865d0b60b941a4f732a36491679e7cab78accc41b9a8cb0da48045ce2636d3b50f7b376531de8846fe118fc46584ea45d0f7a2b9d8d51c12e519d408c6709f2e9d8d13ba954f2cae1219b371cd91aad9ad682347f71c255a71b592e5712a8cfe0f92439fe51336cf0ebb64a95b94936b1cd44f43a789b66d68b91ea81aecf22a70a70c27d7f5af562c59b7bc3dfa314d11e8bebf720601eabb925e21b8759623008eb2d1a6df5c092dcb88665fb52737227966f700f7fa2c0fc4308c460c9a593b5847b80efbe4287c816d0b8b2bb6ed8957d8b3ffceedebd6350b324c8b606f733bef54149e4ad624b6b65fbc00ea355090c811db49642dd64761f97fe0798dc7d866fce710dd5b727a58ce6dde3c330a92458fcb1abbfd7c17423b7f080ff47f1fe71773b5faad0f11717eee27ae2d438ead2e0d5d6785771710271c24f9f6b584a8a2bddaaba9c007617dad520d4443f9ff1a4793b2c55de39f82f02b4fc05582fa51e2616cd4788aefdda4fab564ed0c7928169988a618e975e250b079fb963803be4064aebb10b544282ff84dfe3e9b797fb027c17e76fd7fdea93701547c079d947033af38318029317b9b2f11a9766b6488eaf61f9590e36a47ac09979ee61855f669f4cbaa4a842b07a8180fc12b263068506b504d42231a67c5b0f7a685a61794b8ed70fca3ce66840fbdeba77d3e45ec383cdf3ee772f19d4ef8338ba943c20708d374ebbe2288e546b2987f7b93d3721a1fa97bc6906fa3bd4525dc99899bf5762829321997cafe414bc8ef51c6f4acad9eeccc090ecc35e62aacbcbf90dd5a272f420b157e399757553df73ac6c1fc465fb2e6cd008590de891d4a8f02c25add429055308b3ea98a8deb359ccb3bd2363fce5b5feaeb8eda6810dcd6f4b04c410f6fb2dcc5ee3ee6aa8550c7931503981731057aeda8413a1cb344927ee3f24af1accfbe9bd4b632713c05bf6d9c84abeb9cf42ebaae250573b7731ed08c6038ecdfa6f0468069714370c2c587e3964a142c96d62cd8cfad38811c932534c7d3fb45fe1b1c07fb677c6c56e5f318bd7720e58cdd01d81d842060805d682208d51f6ec3c4b4da3113f4e5f2b4a6cd98a3f249f7ef47a9424dbe74f0986fb6da61b4e806dad5aced0eb33385e3a16f7d126fc6462c649fd035f8cffa44803632aa164d491a327e51d6c05b752295b14437ca61399524971dc2316991610eb22638015a46fd6db939a948c8e8c53f911ef75cca1195ec3d095cbe696a32fc7d7ccc47c2f238d2d5c030a4f8c42e3ca484957f2c66257edd953cd9de47d31cc006935e1b5c1be6217076da103773da9a8daf98b60cb4163bf79a2b2b7bd4a32b70adf63ef2a8f880f2120bd32c795fedc97c19ef36129e9de7c207c6cbd3965d9485b87a89e6e757d4df4c4388161307fe3ca08183412f4300be225130e87d31d8b1dd85a48872efa9bf60dcd32bd662c52c8923a5e7b02657dc6675248928b9f09d35dcde8172ab9a261b3ececfc0584c8dafbfb138c7c8047cbe9f6b2de12071d2f61d6f02392ce59469e8f0befc50eb7027c42033382d8dea4b1c56a2b79bc712b7297642a7eaf3fbcf727bbf53c8b8b5fea81e035a6f79317e7b897a84d9d28db653e94475b246da2552d337d01a9947ac360ba4b9b72a20f9c3638251fed3fd9c3411a36ac833b3e43abac92b33c877eb2958c4f73a88fba172536375cc0e349ec096d4df97a119a085ce203c8fcf169e4bc92a69dc5eda575cf3f12c69a16aad3f971750bfb4b3c4ad5a8939086b7871bff9c404a99fa7e52a5b191305c8480c5d5e37cb38d16e65d78e95271f61d21c98c89997cbf0caa902f33516abc8cae78bbcef591580d4d19761e4dc29dcb0715e94cb61b0be74a91e5d5b05f2ad1b6e882d442de2623b88c468090eff89763c1a05cd9f095b4f0a165a97b042df89c5ae2c11e2171fd95360960c0e0fe06ad07b4872e950d33fe5c5565522e77dd1ad64f97631e3973b350b9b7c2a1b6d486e738f74550b3ef9a8c58cc0e912a38fa81996296fd0616c4d4eceeac21105f0b21dcc2732b2b82cef29fe68f3ce2a00082f8582a987588a0bc15ad105ba946719ad494551a4bef15eef20f8c775ba4e5ed8bdf3f598fac328641753b1dbc76560305cd568609de73be0f1831a5d5d1239bc62b7547afd10ecc7e0ef3de4bb0b6bbfa4ce81cef8335050514e5ebb305dc4e2555f73ce25e03c5bcd16f89bdfcec5b9ba2e994ff068e28853cb120dc0b73bc13f285abb981a4c65cfaa86cd306c7dbde809335f0c25d08cb2d9604ade5b8e3a2ca86c303adc0e145b76264a0c170f3ae24b249f827f4decb24ee0206471c14ef940f29f8aaf3888aa6fcf531b913e63357b5de016369726c4da8ae38d22afa3a83e0e1ebdb6e784cf1dcf6ffb8bfd10a136c7de21641b842b475138c720c16af80411736678bd9b976dbc5491ce64a74cb2a0a9885140bcb9287581098ba3b0a668540250326041a101f38bbdf1e5798c870b14ecff42e0a1899d8499f44805edca91160d95d210fc9a8485284fedcc1449388c859b7a78dac84f3394cff13a4e9d87dce876d6a1982302e19fec65932c8631e3232411fa48cb578586a287948597e78d7ececfa0a6aa7ec0978692d0adce36fb431973a8342492b990bda654b5d1372f62bade289774dc501fe60643ba0a9bfbc54da5f332835b6df1b5976b36baaf0d159f42845dc760da0d5d694320086004fb9b4972a64764174c61e23bbca33abb3578c683b451bbccc749863a14d44aa40e6e1df426f7fa878ac2289f5badbdaed06be29c2c023a64a115b9fa31b94b5fa92f4cbc4dc5fe5b363837ed38991f8e1d6d20223663178d203cf5d461446cdb4efa5598e007fa8b573ad7f325f55412edf5090b53face5a80167cef3e74cd858b22e242bd767f8c7829f63865d2d7c136d6eb5463162237b6bb4e014c238d00b22839761bbe22b86eb541ab33d930f8136eb159ef12ba4a79dded971cbecd371ce892b87c217e2702a94a2c9fc4f9c98a20cc5e957d1c3629b0a5182e2fb5b460c90707f842c57e4274036eb0c3dd4facc328daa95db0ec73fcbdf6e2a75e4bdc69677e5662ee862d6e2a8eef8875c5785a31a58882746f897c1585a5b6b7c2605d61a11525cab5f30de91585c5010bac89f742ef3aa3f3f0af581265b2344758002e6b23275a912b0f5d2f9847ceedb59dad1c7105b458dffecdcff64cd30ca75d9eae35be4fe3fe1ca4037b334916e33a772e6c02ed1e8fda1595d84995ce97c399f3cb8d0cd499de401ab003f83eb23c3d8969dce03d54bbcb1dcefeaae174a6d2926255ac773866ee0e60d5ff2fb372a9ff26bc6c75b4c072dbc4757debf236040e3101e8fea22cdd83944d36bc9ea5c23ef138a19ae17a47a93362fe40150db1bd906441ef1930fa2615d6b32875666c81f14b21e9e06f28cd0aea6b5af7df2dc86f2539b1b87c60e0acde9f5bbfb90dc65cc5739d5b64fbc0f761d97a12529f335aae9e0914516f8c7ae8be2092e0ac822624c24fdf63aabdf6a8b4f3d6d259c3c4b2e2956547b8317619fa9ad079c2a88bbc6c3086f59cd828e0fec097e3984f33fe5bfd5328dcf08fda4f16fad58fa2f22edf7a3a1bb384b79f307cfe5c241d58783da46ecb3d8e18208c365a79e64df5cfbc36638644792d629219d60475964c1ad75da3d6f11330c590d58d0c05e84236cf401410061fb8fea81fd436977c27637b9c84d02d0c644f5697a8e5dcdc81153d29e707b2d638a9e3976676f1ef34ec9c7ed60784ae88cbf8bbad1fe5c775e7ca64b45e66b5323a1efbedc7fb9cbf901b18a65c59c50b38e9d1489cc59652955001c341c774d6a6b2a9007a3f7e2301a6e5cecd4d2f56802a5d6c16c0843addda701a9bb97ba55c3deffa51bc5fee566f2f78fb845f3c32e23a223cbac51195f2300219387d1ea2b46290f22f106838ce9ecc6a1a5eb655e05f58c6e9bcdcba5927cfb4346a7e16b697fe4de612d30df4742432579fbbbfbf8e4dec9dac339d01f9fd0fcdd83995c53f372334f646f8c6d226c081cc656ca70eeafa91cca8a0e6776f8513f0e2b33124aaf77150d7d679e292c2c7308054b13c87437d419d85156ed47d3a2b8bea2c4c0c272a48b643ee32775ceb57612567aa54adf522937a847c794084d513574cbd44b474f69dc1f0b48fe54d2a384a18529aa8b49b75de779295066d4f4e90bb1f128c727a94df3a2129cc0d92b7c5b34818283a606ea7082d4def9fdbb67ff867903806ac938d78c29a2f7897ae8cf5dc1a1a6f058fd1807868827a13944680c8068fd2afcfb96722fb92767972f46148befd8a38b58084ed858e438858200b1564ae411417422a3caabb20733e256c854cf2bbad1166054dc836894c70dac4e9710b659a43e98a216d92cf0a658839cc7ea6faa84adc4cceb95a30c4d0404963da42aad96a1699af03771af56b86736fb30537a75f8057aebb210a9f1f1b56609692d84cd16c468ef615667f232100f3bd9ee16fea6767604de3753870806f0384106a406dd4120f125ac05eaac13fa1874c1c4c015e5cd886367cab416311de1de4847e1e046efb0ef23df6bcb8aada3944a28512a4ee8230bf1c4968ada10c10ef1b34d9ed8fa1a7deaacd6308817be12d023a4e283ffcd6c3182a60efc59776ef380aa1c62d2a56a165421d2f71eb937489593ec5907af65a68347fe9f060d6c3d128723994c46af942ee8bdeb20890f12f65f1d2fc75365209e2784fdc67060647fcad2896a7ff1662a73223f5e03843099af947fec689ad2ac3b9465b34c1c50e4aa60f2a798866292ae2db06e37a8a9a41303c38ad5c2f06f3ca94565f4c976de0e5c17c6d7423bd6ea29b63a9ac0f598d254196620e18bcd37baf9304926ef88cc57b73a7ada7550b6a65951bfafa2cabbf36ae86afc4860453c5821e7680cac1c4fde96fa05cb9cde9fdd8867f0106a7b78766369d4602bb914430eeee6e0bf49e4a268c1d38fa6e9f28fdc0a2f66d729c9c3612869cb303f4187c91d288684a55bb6015d3bb1af15f811774a22ace93b6511d7b17b2065e3b65e1d978f5ad8e860f3d7bdbc551a5d1845765d4fecc3e8f95ca78986640aabc1681c635961f7da92513ff7aba677c04828bb195e8bcada55ddf612a6bdaae91b4638ebb6d7b58d8fa6fcaa503472a5e49ed2edce0f53bb477d7330f3addb7db36e5b22c212146925b73e660af5aff697daf9b5c0ddf5bfc9bfd73fb8e90f81cfdfc73e77e077c029f68e7db38766b87eb8a6ee4c1d53c60a4187e9e6ac7ab2e254bd418b2554c6bc99edbdc56e540f9546bcfff61e4e8099d01e06ac760b284e9d3658b8d43f9ff231b4d197217f76eecffecc3ef1628ee1baa8d66ec7d9615facbd4c4004fdc5fdb7df3f5caa7bceddb812370c727788b07b3393516a1b5f1162b0e91ea6c4bbd08d3fdf3389851351c7e365dc74c0c60fe3a4293b314ab4f9290ddb4172b53a7cdfd832844ceb3b090e36f44ed8ab6e4bb31682b2015198fffe71ee6b7ff6dcd55cff5d73bf8c23dc1c88dd9f2361f9b8280056290ee1d2a884d0236da870b1e59319e398c54e82019fc2b9353c60d164d2d63891b240a5ffade801ccd9fbb5533da450d8431e38a6d52b28409851a33a903f3b30bf11ea8d3f1c8e3bad5c94c0de7a4dfba5fbbe9bec701b88bab0e78df2e1058b610c7b2e9e45755c036569b96c3c23b5b973e15cd38507712804f90eb0bf40f7db2680e83bef31230ae12f8bbff0fb2fb08c211c04f60f77bd37c98954612a380ebb37e69239a5c26c3c13c739f97f6caf03b9d6534b21bf88f673ca6a1a7c28a59f73fae3427c9b5cff178b007ee5dfd95c69e6d159065509a608a39b6871467553a0f6144a6e4723fc3cd663712fd8d06c783212fdc45cd0e6fc428a39fc64ec89dcca035005425f72f2ccf09de20ef078301d1f941f3fe7ff30b59117d1276e46f70605856fb940cf525418a3e6fbf1c5e5197b80f2f48af83b6286d8835253603df7733f78b74e21247866cc5727a393ffce0281bba9dcc6e03a9e6857fa40e952d6de9c7a24ba14f4506006d01e523f76e35d8ba6f5f5b485629741e4fa3eb43e3ec59bb0ee51f4e51901aa3367842642df876661f085db8d9cc0c5a8033c4b190a607c3d520cfce06e489cf8e212b3b792878b10aaf7822857415412648ab43093b88506ed588396e5e1259280e7f55ba49cc60db623e56cb9f508a55e7ac9d7f74212001ee0cfebb67d1108477d4d80a02ad04e4a4c4e122e8e66096a418652930a0c195ccf1115bbdbe17e27ed31ab4bc0218f4295e3fcc4dd1a75b05b2accee83b2a07e910cff00de57d0793c16b68b5554207fe242a5647abc6329a0c876a8f2f478bcae5361f3aa12093d3d1e4f15db230d57994357b84ffe1921fd103ff916b1afe0071a74fc3742ead423db243bf98815a3d24e81d6a08a92bc4a5a84925f7d4492f69854b231e306f1062f5834b125376119cbb749b835cd368b89c92707327d6f1b0caad97249b70a8b3ab3b2904eb612e2575ea255918d7f1170f4f8b2625a5677a713d868ec2959da445cc95fd8696c949ec3490d2786e956f558fafd5061d0b7db5433b5bc35707876470afcbfddf7fe6ce3bbe817442bbd161e80fb31aa4b5ca4e1e75963030f57dd3e0307d80526fcb07e41fb47904b6008f79ba3a0d30ac11e63750938d75ba319d25b49e8755ca39794e029bdc4460c142a9f831e902ca80e23cca2806d669c6cbd85b6793bf1eca9d982067e1ebd596c29200cb246abe226b6cf09be6513c185ced5ffa38a00d334ffb81185d0155a1de20cf23466653c86ba561b96ea97fdb955542f04c060bebf85a71cb2359dad7f631385cd7dcac9712ca63381cda0a55f2abf7d43b2b6f64e4d98583cbcaf08a249f352d6d83a472ff9b6b21ee06fdb214dc439cefde2496073fec1c528b5f31c51d2c071a0e6c690ec3b591e2f530e8b85f302f17e809e79dbcf5c8d6b2592bad8cd7c86b695423dd287e6d61849dfb59b2c2e505e4e4d10f53c046ff8a827b7ac099ff09cb9eed05753bedc54aba408f0318ad6245eb65b2cbe2a4b8b9d848fc89054d4d4b7dda1c27d58667c99c946aa3ade009679fc82419ed0cafd52cdffc203788ad08fc4492688ac95f25a9610b327facd9feac14b2996327c63a6188e26787bd2857389597afe6e9f030890819f1609acb0b15be13531f298dee4268bbc3d9613f3fd2b393286f4e8591c4f3961f5c267105b6a6b5fa8428e4be4bfc0187ccfe4e023aeffa54577efd336396d312dcd052e48ba6f55c539346f6f2cc32e046671d0b27c58362c6a00304746ced2b7de4347c11b6a57dddd9f790f175c23644c85ca1f4becb0685631a7027172d07604b0151af263eb1b22efccd16e22ebf610fd35e952fd7b1c9d75b684a903a513da2f105a694042a6640918e67c52bfa038fd742682454769f3c81b9d8fe898420280a615c7e29c92236e93005e121af77283334ac2576e52d13e0a594678f923c383800d3a13a091a7ee589fd53acaa47c4641766c3c237dd3f1f67cec1db3caaf58084ddec8020c4bb24e51dc424205c3df6039cb7d69b06c89a0fcfb78ccff3308b10860714b9113efb4489a46d2f2a53692f51777efb101e7ed0c97fe85bad87d9ffc5ea48a5181dc786983c2546a0b21482589e478908ce5053a162801ec0b8bdb32554d7315cd3d5db07cec1d315fd310a0464b513cb588708e74309547fd647bb692db96bd5337f738964e34cbc5c43a40b0d96a914dbab5eb950ff08f8276accee8f4b668b643ec53f1428c78bce3ad198cb89f0f7af04d8f7e6258840b1fc33c97608929ee0bfc8dc8256b7f670096b91b09f2f641a1c9b8b83541f1198f85ac4d3f797b32bcb720bb3ffc9c29b91c5bdf7be5140fb1d3db936206f3c944683ecef29d8daba1577459f78deb4a44be79581794e3bf4c6d8c6dbf0ab868da7efc126f7bb84d54a140bc614ba5e5841cecfdcdb68adf25cd22c87792cdbd2740d55c5af9bcb80a0b8f7830f53aaa13cffa4ba3568ce4b2940cd8ccc9457a6d7796af8e879d74419cca9b3bab1cc6ee27d778c344128cc8be348d31e677ebd869e997910907474130292d9a433dccbf5568d1f6fce2308f2fe2c5c038b26894699f06eec8dd31cd963955d92b14b53fbc0ccdc6bf72da35100618a92089def0fdff77832f077a1e93ce40df8d2092e489cdc140b37ac2c05c23a9777849c8cb9a385a34477eaf8371718dccad29bb2457e933dba23ea4da4994393b475b7aa5bf789c86a197a78a74716db417414e87b47dcd4a456aa300dc75844849d493ee23786a2435e57d9b3beb177dbc548ab63a7267ccc516ec1bc2b70b83469e02ba10b34b1f75605dab59a2b4247ef06199f123676a1c3c5adf6152f3f0422521ade71d20b890817f4c12e128d50dc364a3f0116cc8b5f95f8f267a74e9b1433aef443f4e61de92b2e5429cce7aa59843d2d58d1b23dc78ef01e17f1990eafbdb3d8feb26a7ba43a699fd103fa26d6cc5329a508998f1c8d37ddd8f9dc97d3c125a0626fd9321334d12ad4964b182ee64bd30733f73c073b564e3632986e0b03ff5fdbbd36d04bc1f0e6c116146b1be4f64eb7363c9ea3dfc59d68f56797def1e790d79237d531b4ce979a9cd3a7b179c74d1cc38e7a31833264b762385614d7b1129fc3b33851da7cb510a9f1b1944eba0fdecccac6eeb5a83d0ed91c51ef68bdb20c4ebbb691e95c709d834226b920452f755f3ae9a8dc545a8cae91b803de873e44422a0d4ba0174bab65e4ad0688aae3bb94b283b562bec3ef64371c58470f3998a72a26612f9738218bde4376bbc41802e2858cd90d0deee25f4f6e527c33c11ee3b8dccea92556598d055af6d67165f521d55590a7f05c8014689c56f3f2cdd9f8cc4073c74b538340d342ac40393f9cc28d4ae9bc7d845603b237365930cb6767a3af9758be56aeb7600d2b1a647c7a63b5b6e1bcce157e328576fa94d846653347687f776def2859e252b5756ac955bef62c8fc7f53c256b7af9a6e878d9ceb9a6d30895641e878f699fbc03d1a820cd6f85638bffc762db5e801ba4b1280d5905c6f06f1e7b11bdd6f6f9c33355782dada73c5f4eee92a3da85f335b44fc58797e1c29d087df1ce927fb388f127306fdca836bcd50077b2a29056c9fb0dbbbd72e3529e24e457deebec3fbaae11d1d5866409334717654e475048ce96db2b869e1ad64ba1ff4e4c80fffa158fd7e016278fcd1e46faf86752246c95547b0e490152fe6e5dc59318f40d48022d1b684190254482fcd4bd5ce89a83581e40c09fb2706968a6d273b6b6adf805e5c12d34287e451d8e1ae1b9bff31d81dd2bff64addaf4d5ea61b62bccdf851175d401104fe18c3bce05a5fcb0834f6b705bb2e3999d9cb930e67dd1bde40c98579b5af2b9218f3c329695f5a1948f7b6a124d38d3445b7c2fb1b4bf49cdfb74e817026d3693ef293475898816cbbd3afd2dd01ffa035210b15f46a1635ee9d0eae0f539c34f6272b27f0370255565ba80d366b1aecd0bf6fbbd0c5cffb9f56cd9bb6ea5f91e43e07a46403729b7d155a88d88a442aab85a5d979263c9c73b18b7118c00c84a4360b1f5cf5fc55f694f7abfc523419fc6a1ba74ec69ed4e97f1ffa5a0963858718d70c70b75fb755c5be0e9ac36b1fd5890e357e4525014c258e4e57a937059e54cc142cbf71985510efde9fbeed277acb091dcf9198a5cf5e56e9a6552d32cc16d4677558e4b3972b5e121d89dd035b669fe8960b7726ce813439aab11f72a8d22bf0fada895a19663d72218e0955fbec8bd0ce1ebecc09b13da128e67a128eaebdd0a3226b7aa7cea4ceba7c1c70ae404bef8620f93b8e15f18da4b6ecc0fbb96311b07599f055a64c06172427d18054824695a263a431274760dabc5ee427b862fe214c2d8f212b7b74b3dd2c4b60e53732c94a80b559a04fb733ee265f0421ed8db7999786a79429ccbdd5e9689e4c03e5696540eda9044f2528bc2ca6ef7bbbd3c0782f57c4fdebd53bc22d557657ed025c6699475996c62a325270f22cbfba19522df374225aeac3f75be5334f406ed4ed0e18a866cf026d0909281a50932fbc6783ffb073b0d97fae813e471ecc98bb573d1eb1e7330c8a08468e1e98da488cae76843d9ab0f7f98268c8f1dd8266d5c763e7864dc941265d7e7ee532dec3b9b927d7b69cde8f7cbd90d4d36646c53eaa7470156321982f3ffe1dd70d0690c68b358683482d09b351cb7eb260834d352ab1e12305f3a69043b8c38f403215e8f7c7a1bfbcb513fc2b96da6b3b7ed1246cdf66a2d0078bbdb74449b281528e75fefd91f3d186a0a669121b0e3992dca80368d47153743f0a1c63e4d785ef07b8389630fb83254a4f19bc854aa2d6b2b8d817de9ee109438e5826930bba9aee9e45d026216b72f9366bb0acd14daeffee21ead0fcafb3a9c77b8973cb60202d6df50c7fe581c9de67377c04abd03cca072483af1ab0f4cf23b51c0433641d22d96b15b5fcbd68421b6da486e5d2c3edc5cd6be9c2e9e4c34f3610431c30b788d23c7d956ac33efe8bfff5eb23c60042d9a20d3ff21c92abe0247ac376eaac8498ce95e9eca32f18fe7ad0323af9128317b3bde0b0fb8ae278e400573ccbe8b84ce3c5a0358f49a7a5a36c136f1a6535c65dde52a76362e50dcdae7b4847759e02e1df906859c4f10c3cede9a90ad847c329499b8911f8c9e479c89a4a15f23503eea123d7b58a4de95430dad677655fc203c084434cc84c467a77c6fa275dc32c1444807e2f4f8293562f1eaa3bf27f1c5db669408c18529ea5eaeca0e879b110c5670ce2168ac38e673584c7d6eb423636c43d8a18b3942dc4b5eb477d76060ec79d51ed9c1b9e271055aa6ceff05f517d59d3d09b7299cd195538ff43e35d5f23b03197a4fc3a81fb698c31f57260bf1f3515ef8df91414fc838c54a42ba1e003991e5495b1b94654debe9b5dec03c5071a8bd384a143855f2960445b54b486bd5940b909f10cff5a90358cca70633e14a4b0c93c65d62d67cec679f7c609cdc71d554a9cf4f9b03b4101671da8a0dfa173b2ee300c600a3015ed8d9a7eb925161c856cedd7203cfefb66ae97acd61581627007f0d365a14e383281b5f41b86802162db9ca42cc0d5b4f054f660e67e0c7e0c3f4d92c533560302d62afddc9e3de8c9a49654e6bc3826ac3b20823af09a61109837caecb91e116fc601c65d5c6f97b8263bea43461ff58d5bd5c24c723ff53e0053b2ca42fb4b41a8f763752d9f34befe99a9932db13cad47ffd5c0cfbf335276d9567099b1aff273d258ed61155c9c259524027d284b4f6f148a4a9d764e1fc4e1ca6b6b09ec47f23ec688a419e3941d577f7238406643b27e7f64fd196a9057496255a4684a1db95aa7af58b646321f66c564c0e3f9cb2e36e86db31651ccdff4a88847b9e995ca731d8cb4008878f53b08bcf6380d2a2c466027e95137775499874803d6643550f9212f76c6c4afa92512938ff59cb08747bfdb7e1eb5349da217be36a7c79f641da2783cfe58940b41364f18f5495f0b441d96cc1a3b45854e53349bc95075e1bffda56e92ca8a0823abbed734d766cc5111f303c88c406d4613ef080c8104a65764ca4b59809568ed9137f68747988b670e9276a606e04e0cc37cd04eff7313303f6a5d8e7a21e12f7be36356835fbac128f9bd34d5b313c48ac0e22fd0eaa82b9ae10abf0e387a93a8ace898d6bb0525e4359116d81afbb918a362dab0ef5e3c2a8751cbb372f269755c837cac6102808b94bf5bf18131008fbbd04b4dca456254808c5d6d7ec0daa2c3c49cd0e22776cd9c640a00c91951196f88f1222a715331815631f813827fcbff2b98782447e4c029592f05d3bc4133ddd6deb42bece48d77fd7faf46ce16dd18a1b4b8a3a4ad47dce27a179b3a09fca23a2f954c57e02d0f35f8cf7b9797eb00cd67884247006e25762df446482f7ca889fb61f680bac1a4762ea9f4d29e54f0009b157b6216e85fde28f4f1e4a968fe72acbbff6d56f0831e108aea80ed76e8a6df962a07b11a05980e9b96ce372c70d3eac9c0c66f7514cf5722498d10875f97cdc37e650f22346d46ad45ae2f3a3a5c10c4491589e7590aa4fd1b10022e92b630eeef4d85fd7dbcb59d0e4a59bfc5defbd0cfd5c4121802b7d363a7a445dc0380e0bc9673a8a83820ffec8153878a1c477e0c394eeb589d81d84137ae22cc9e84b904b21732b0513a953c539868c00e55161977d5d73eef1a149b40d3a7ecde4b8816e80fa41ec102e2b9c7332304726c5d7d14b2143cc8d789d7f9abbed8e70e3a126fbec7d2b738b90b87d72cd4324c6e9e6bd825f2b081191ff78bafdf4c0781d6b7ad64f788d7a4946f55b09c52dc660e334fba355a5d6d1f2e5481527b260908f6c5fcd6f9609530fb17e1ac72a18b5512a18149c239a4654aea75413f4d4465cae9f5468a1b04e16c9eec41d2c34b7c9206e2dd9d15d7784fd85928ffabbbfcd2ad7f40a8e973b86223adea36fab2a31d6bfbc44e5a790477ff19a9e209a39eb764d00e437be7725637ca299043af5374ac8875778c71cfdeaabfda13480671983be8c90d80e72d16498e09bea744c35c11c829448401abcea62762297153c7d85ce41acd387237b917a618e2cea82c4ad2ea943924843797a60ee2db1177f60a4fbed08cf38f73ac79dbfd97b8df3f162365741078028666b8339ba2d5c9b9afb90690997c7c79bdf3d1b9e4883d03077f381b112a0a35ef11faf0963ab6f305321f11fab6e7f1dafb4188bdb957230b8f9ed26b58f683ab20101426c9038830aa3bd8d7f1b7764b6075548f9051ce87e2a2f95b2df71eb6314b08a3bf77ac498b3d3ebdeaf8e24a3b40b58d4c64725ba14c1ed65dc65bd070749556d760c7234182d9f718abf04966ecb846677fd41b6429cea619e4ea7f3bfd16eb6762053a2a7967d7dc48b95b9e044a4fd693c78acb245a0cb4439630ce4e84b465eb2313feb51fb8eaabda0d2812d19b746629f686fd0da6b99091d7665e2bf54fdde2f63ff767d2954b4977a6631291177fdc45ad64f625844eda74c0479b68383f42502eb01e614b0e5ef6cd6b00882587a2cd67e7e5c5a2ce6b616fb6be3b4a862375d5b1bef91bfdf2e44447178946c0734fa00446657140cfbe8c60667180d249dc60697561b59003ce9513990f2ce1c9954627618d95cc2051349c9c09184289f7f8792dca56f708ff1e8525c48f6a329f70e10bb9f6f27f8104c227ef90deee7b488d7a97a6b2de1745c2ea09c0e4846a0843d1b6d664416917d8a3cd8f1a92cd1fb6462caec7bce4559985defd05cdba33a5e27ee80aca9386c09afeb01edd29da5bedcedfecf1cfc78f1baa2a8dbb2a79918d55aedbcc1ae533af5bdcbdb564cf3e8bd3febe90f768cfa85be4fef865e48892242d332215700f9beb5e0cf4f6a6f54e5ef6cd657f54e00195df36cf4b5784af6bf575660b1d53abad9eb7a3cd6cbdd12efbb48c2298bc55739e980edee8405df076c7483c2c53b28622d59de9761f1c7a84ae3f3035c4cab38a4e4d1f329d8d270364c2c93793ee78f6f07911841dfe7afac70f40151eec6396c34afa309024506aa346fd25e58b9b74a4028bb4f91683e2796705418d8a865a2c55480584fea0050bf4e30af5580e67ec7982e4e535db962d8901d270c8834914e17b6c63644a64259dc4a512270b2cc38dc496e6036f36f8fc096086e2b2a2d901a711ddeca24f7ba718c062c043d004a1fae8c98b2dd01abe5c7aaa0526a6b88a6ddf86eb8f5f6fceafe5dd096e2a42ad400618b09a3bcf6585db7dc232487f9d081ec856a7033dbf558e748eca057120f7c3dfe02e66fbf4fc86f422767874733b46dec98e3665c376728b260eff811733019bede52a0e3141e57933d29e8ae4f13b8096884e510b5826f8ecdc38f28369144f1c88d5dfb7474744fcbb1b9e03ccb9527792bea55548e90b34fcd74bcd97294b0f0e33e82a3f73e5c653aeb89e85b87b16f517ce8667915344bb44e36258cd2fa25e37c04dc9a9b0638c7b5091cbef5acce91ba5fd85ae9a8d23d9d1e792a64c90fb73642cd6d926a78d7fe622e06037d2989f840ddb2a457b7f01f780208d5f318a047d9e06befe7fbdb59da98659fc48800b6a0f856d247ab54eb4faac49df74c956f8385bcc137b1a3079acb3a67cca432a929917ae7c9e8b43f074722183cfc09377f45545a9e2f397dcaed9f43579f342685f6a9ed8bc659a9797da8221f6b17cfc73af6d902f97d89be5c389fa8ffe9979dc2a47f6fac7e38d9e19c07cf96cc2d2bfcfc682fea9b13ec74c49aa6c7267c19c95240b441aab6573deccb0268e43ea2a2256b3a598d69d5a9f71c5bfebfa68fb3fb508a7ac3e696e48b481891192ba4930f145755b5c56133d3b98cff9c5a5925bfd475ea304db7d29b21ad20fad1b0804623bf992bd35f745e0cc046ef136f4a974baced4e22282fc7d07a2c926bebdc9ee66f198f4bda5c66060caaded8ed178da8804fb926644cb63291321072fd3a4ee0831c5aadc8e7826eb27f8764eb001b39c80d101fded03339fc977c342707c090fe4b2b1ea4deba6af5a528d8a2c2356a144aac04c943913a139add39d015944befd3b0974e83d83de4f451ea0f76e446d700e81364616d856e6f347f292b027f5684b51d1c1b038043c51df98cb21de809b22b8d0ea0d0c8242e9a081d98a1d6f1d82cf9501ab6264cff040e02946ba7a88a9e70ac7f54084c5eca38a0881ebad0a5c6089a20fc5ee3eaf7f7db35fb3bf312462e32301be0e158b6ab4a69d220fef59eac1668304f6e47325fb53cc1d58afbb8e766254ee37a30888b0d3ea180325313fbb1071372fd66a99e4983c73b922659b6941a44fa14a950ccd2ca5dec87975aa474106c27bd3b48aa7aa7ede2bc7305141df659b1ba97dae397bcadacf4501b3030097a2a68fb0c78458b639b96a40c2dd79aba732d1d41354205f217e71ecb35b63f3294196dd867751aa2969cb87d2b573130642223a27dac9f2349da9d2bd8e22cc31ac2d47785cf7796f14df84912a6e45ac1d1618c9b605857ca651c86174863d8b8118a5c81ab8c7a37d708541fc2f5b60238a4c18e4b4f12c498a38d80874c6fc871faaf04ec0ed3c8b23f2fac457ce1c7ec56cc733f22bb9d070a25316c2d54b852bd535dd09ca53b09e9844aeabe23aca8c038035652f442909caa929c8db607e0fb9140f5d4298d7226c1caf29ec8cd9f1722a2745ed5c693d467b8ea5e1986d5f2a1e35d76ec1e40689ee0c83c33544224f04de9fcee5a08945a8509c5042eba7baf2ada1b511e27ad08fc679d7dbcc761d030b30420d1717a12c2c507ea5a97fefa4851b8d1bf1c5befc04a5f0bdbfd7d8e0c79b88649a37f03ff24a9ad1c43ba002fd1225de14d0894604624e89a6c1a80b5d564135af8c7354228373dcd627aae42c9b81fa950cee349f23181042f1a608679f04056949ff0a33268a562612d2a11bd6f69265b9240e3a1d7c32b164c77347bcbdd685464770833da7c7c57f9459d0a480ab78777e9619d8353a9a94055afb8057f09b67888271fbd2a061746187a705daf28df59e65fb32b01f87e15d63fe5eb67ecd27b8acb87a470ef79df80b3a318794ae8c11854ed1dd7f732a48fb4a7313750e50c3cedadd0626e3435d0571b34dfafdcbc5e10e552cd802694069b88c88e611461f241d5ec2cfe80cc0ef70927898afac2616a5c106ff729544327882b273c505631fcca235ea15b31281874c26e6d90a6dd10321e85b7a231f441e05cfb86cf95ca75c287f6422700374b55f5139a0db6ba9e6f0a0a374469f405ec75b8cfa3e7280bbab2a2a8c98d91e32f4a1bbd99c56c2cdedfb2b9104a5092246dbade538068fd2e4cea0f016bb0b8edc57c60425f406c01ce76cd207b634190466b7da924ae5c3d50b171b398d8bff6fddb62d1c5f361aadd9966494685deeeb71566916dd0dc01818a6d83b3ee4fdb9519e0b0353ef5234aeaa3ba00b7e97861a41e974e207eba500120057ef8c25bc272860c2e6d9bf99e9e390c27be3973e84fdce4984377c239191f388fd38bad981f32113e7abde54a1b2ac5fa456f0f13c7968e70e317ebdf1e4d25df7f85c46ef8555c1df736f72133151218986bcd7aea523c0285868921126585d4eaa67290cd6a9bbdd3f78a21d852e16d07feeaa033c7b22bed630f051bfce45073966b69a6a2cbd072b9f2297b69f220fa4c334103e7d255850384b8642e34f546f4c2444bfb0b6370029e34721856957593811c722a8a127f3a84f9179b50055c8c51092eb2d3cecf6cf2f71ce12471688d049a49672a943c1c4e586234f183deb75dfd522de589b5703713ccab20c285039f9d4c2dcf821b1892404ff0b4228bd44297fe9de1315e803e9334a648fb745106f0aa9abe67505ca98d1e07744e00847b0355e5fec02573444cab132b70424dfb15cd1323a43912c662c2b2794f1a07cd00e3771ec9ef1bba34c46dc693eee09640d3265a8f38425a7eb7e3d11273a1a1a5ca477edf66f2fe8053c7cdd505da17d2088bf537ae454f5074916ca8dfbc7c917295b08b527db2eb8f790cd553df0a909a9b87bf812d0753015e286917b3ea1c28c9b1d14cb3e140f954a090ab7fc39277f14c7b5336b6f7cd1d8dde4df6682ece7f0e07f9f5f2ebee99017762046dc30318af5b0ab85b4e7fe6326ca29bab3b63e22905e9b9443afd9fb212a063a687b8f6fcfac23e1237e11dd75ac13c9e4024c08ec23f5e6769c28811713f32f888dde2521fc756d2a99d395783d85eb614a4a57e6fb54d3fd11b8e065db8617bd75250bf0aa66b351906dda290d26265124915567c0da662cace58f98d27cb22a9eadf5d97ffc154a6a909504c06790ba11f31e522dcff14db31b6e38d3bafb547eac15d01721ce0542d7cf7915aa9353c589d63fa9a4409069e7a6fc0f0fc8a726e22744f950b7aed21e619fc8ee7bf51e4c6d05b59e6a2b5633b8765ef967400c03599e0cc2e15af7fd7f011db9d4bdf1ddb5c3107c7c32cbec12950c9e4af9ffb7a822f4f8a50b3d2f610f3379a471e144935353d12fdda7de53b1a36345dcd70530e4cc2c5d6e0c75f6580fb04ecb65b81238f789a6fa31fa50f9d93320512183c1ffa8d6192032d93d54f381b6802f8f84013ad1cad346218b901fda4479effdb06fe5c59126ef33630f3c9082d4c699ac1123ea8517d022618f33c0f9f87ba145b1f182cd4a506f8e5ce629ade3824c568106883fa51994ca8ac2420b344728a23a32a1e9c8e3b943672565d0608b6feb5dd569f8843dfbeac4bab8e10b53fb0988a68533ead98f6658235e30bc241cb442e7d2b786572ce2d2bfdcf7fc9d9c5b1927d39bf3c82a1cb24056c78179982c38ce5f4964ec9875a2c61de30b3e62da50d884cba361b7c3107dc19e24b6a1884c8ed908d5587dcd9793458c9d6dafe8198eafa9f33dcc206baeeb7aacc45b1c5eee0d8ee0dd7014d501b2abf3dacbab4d3218ac6643da7e0cad4f81c33fe135d12a27243e287bdb690e4e288f967e5fc7fe76794585e4345431f031638aa1909dc514d2436b662f475db6432c6a3501ce1812cab74cada4ee3d23ea27a1be8072708b4ad3f1b4a43642b83e250b4d3a0db5f5ee54ef0d08d70f15096ac6e61bf1b5a00cf24a55d1cb5dd7b1d199a7ab7be2f4e9b07c8a36dbdcab8ed2daae9c91eecbb24364943cb7c69689e69201105114216e72a456512aadf8a43e333b9153a0f2ffbdc88037f3a8151311d0e8b39a4364b419f0ced085a12a39f5d9c55c9df596dbe1311935bd7ebe1f067b73e0e02e5c31c6a5ae937a28fd14abdd4e7315504bb2ff02a64c81848fbf55a313da0ab9429a8568cec42d6d8428dd5e47b06d586caa0d52c1faf6b9d2e83cd502d4677b98beb9d7535f8372d9dc5fb13a301a29e5cd5a08182f056f25d25f58300cc622e42a84fc99bbf9da0690eed6b82778589bf48e5e6c0bb9bc7df7535f1ba6954cc06438ef31a5db2402e101dab4ddbb641ee37740c7ee11d3094e83db31c312f2b3f431bb406f80775bfd048d80246acf1c82a488f0d24eb0699d07871f431c8977858c8c4ab6bd4da389c88156163dd41de56f01a29f968f76badf470298f4c9e742a82adf8130d9800d77cb8372ebcf5242471bea2afd2c766dad6ad77bf749aab1bd2aaaef33e7a03a41e62354415633766ec1009e70c4e57f79cfca54050bf1c1627857d348fe77bc0dd8f62fed2d482752dded6b924045bbb553aadb8287b177ce9bcf79e51addc9919c0460b5ac26c178e109732f943fb228eca57fd62cefdf43346f7f2b2eafd0a5b5f747c348c785630c370389a246b68af462eb5c1cef0ff42a55dadeff8f33cbc966a9674da1f11ae71024113ee8dccd608c1766cdff5e857f5ed7afddb1c5f7eac5513a5c65a175300ca8abee74fddaeeb164650cbc91a5494d6c5f97fbe02b8fa887abd4f85d1de3763a438be1409acbf2101c09434882e4cabe0f09759e281ca3ea1cb30a6c43033d7ecc1384d9e54387fa44a3d09ce0a8990b4e528d3367e1e27452f52d3e459204335072c46816453bb24c7fae913603cb1065c181b55f4a0968a6c600e6c227818b316c8a0704a9d76bc374449bacec18497a205092c161acbd18266c628015021910506de6d0ac9859f773fa5ca98c4606cafc750768dd6cc1c263c5529681923158dbffaaa0badd86ca99802fac3dae8eee0352e5057267aed684f99c555d4f44b66402aab2bc0c165f32c0614a7c3b85cada36acd282c7cce7559d57395198652d2becad1f4722408ab4ed023588646d4e88df44bdb07a97b6376bb75a0105bc41ccd5dac786af4507ebddbacff55c5d80758823369c9054adafadfe074202840430a21fcf96b1686bca5b2fc1d41132c816a4f392ded3c16e6ead42a60f3bbb9a3b8d41b9407922dc909077185d852e0d110c8321c906451e3bbb0c5055c1db44ae0f6b75ba420e55494a2ddcc83e2b21ae06be4bdb15cb7399be51b6d16c4a99993a363ab8a32dded8c488ffbaa3a2692f7b921e638650d5ac2cfc147a469fd713076757af4988d01b5ba3b51a0623b2af8d08b5455a2aa7c072c2476300cd795bc233a919367bdec03929dfacab6e5f7cc2a4fdfb467ee4087010ee22779db0f9cc01564f9dc2ae6867355a2627385162e544a8658f5c8a1800adad8bac391b5d51eb7cf8207804fad42f7066dbd7efc197f0cf4cfa210b9543e61896d3ac8377811e50396dd1977065772b0bff18e87f9793a98dbc440158497f22bbc5d92d381d6991c1375887fdff5318ba16c69c1b58320b3ba1369d075af7eeb79cadf094ea76cfee0edd611acb9d7025f44340956ea96b90195d66ca4289004d361a27d771081eceb50c147b35dfe9e07f5918555e7a724b71fbc8075935c7cec2f0ed5b6c704a3d49057ca878ca7156515a39ad38dd9833e63edfad47b264eefd19c289eebc56d0223539f16c2ac5dde5cef0c91ad0e5d6ea4fc5b799106c0a9eefba3d3bd9b1e14f98373260a62ceb3b15457c873ee820a109b6394ce803f24aef517bcff544cd39a12617a0886d69ffa76132e05f0e3f289d7d785535c86030b04693ded9ccc5a05048f1bed697380b8846ffc74cc4ca9b9d999d4104dfdd01a9c2aa1b30e8a32466cfa62e37c0c307ceb16a2259b3880f6e78d1543a0f4e428b61924ae08232618c4d17073072e4d42fbcc368b47b83ee81e835dfa647559e6a34dc15a17137dd42a96e5f47395cdda4c107388ee49efe60016b53d4f7b71e54497cd382ac179ad1f77c46b30ef2e4b0b60442bdd5aef0751b02d2160c04a496f7bf852c5553116cf07efea8aca37c4bb70972be6e1c58791bc20f1bb0d72e3c979dc9ffb9943180cf0f533ec998b391d5434b271ba83c7261e74f97c3cd01b7baf212facd2ae47891376c6cdaf767bec8ed61b371751cfefc0b710044cf6317834b5a75b990a9f0191535bac49756021e9b50e9c012ae4369a09108aa63be7254ac1e21293b0e01afb3fca2314a2b93046db604ce1f7a6312c159b1ec2f985f8d3209eb98b0d7d5f02cdf14b8fc6aaf1c2b4fb488313f876235377aa6d407f7633b97266f763309d6cd52480197954f88e03f2a88f16253fde138a731066b498cfd2c75728722442252f5c9406b4676647e86f69612edccfab7355e57a03c1cd3965ff75bedc8bd3c1a814047378b0cd4d14e650b4f515faa554e4ecb1138d9c430bd179e3cc4a4a8721e1ee1665ea917fc0bf8cd42647959afaa04ec6b6f55d233f1198a99b78e4e67f567b2a8a666a71b855373619d22526fda54c40d80ce650901440a4d102e3a7e3bbe26de8c05293741120a68e59c63912a740846b104486b18d819cc74e0aed62eb8b17f5b9fca56c17416f12111c8d055026f84e59a06439d74041c038d2138a7b6bbf4aab50d985d81f1082ef9365dab4329911cd9d37b88af52376605f42a523780232ba230294dce45359633cbab5fcd786ccaa6f2ba416d8e3e5e79cd1c3b76f1ddeefa07d0da7f0fae17082bc328a5c1a950b840db24b219c88b101046dce7e3ba773ff0234478efef73162993b051e621e0cc233eee0152e737a9682e97c8c3ddb8dca0258fa07ad31ba04fad0fad59d74bb3a262477154ebbf8bbac42e122f54a60a2fcc803e4bc996634db0529cc0bdb44fae1ff9846249d116e26ab127d695aac3e38f7256a26724dadd0c88f2a8d3baa8a79d12e334761a5a03c14570d69d5c45096a7e60965cad01d1197d1dbe591ad0fb54123e39fd68f21cf476ea833b06485b3acfb5c1ad875aa57bfc0df859971493c043d3567e439d88784dc7174a9b6ed2d40a678562a6218fdab6710fd52d953483507184e62f98c201ad336fc44d9f37620c9da228387184de6b317801a4c2ff1006f68ff07d447c978294a56b448a489745465898606982f9a7cabb26eee68ccc6686f25b6ad0848c0116aad0d2088662af575dd0bc62b160707da789f69faac59389374db2386b735d202fcb4e522962e39ea1ab0498491294ac4d9ff776fadd27486eca37799b15fe8035c65c2b3294903b0038ba27172b5890b208eeb72fa3d855e46271cb34a52d606842609f85f41eb994fac593227da201766b8cd75f048729b1957cd9c7fa9e5e6eb5843ac63c3b0b3ea02f6cba8d9bacfc27012c4e7776ff6692e93ab7ac313662103c9489da73ee25290e183857c9384f3c465821da59d3adb5ef010e62ebe2ff7157d2a7ee84fd0f2e2e9694c133ec9633ab18660797bff4641ef2576dd5210342fd9c928e8c71ed43ba6b0eeac9084c399b157703af13549ec821d8cc5a7ee8b46821d5cc2a3ed94953d4a28f3fb3e3b91b410fbd80fcde5c2fbd7eb0a4c0e07813c493e0c58d1b4aa3d6fc8f19b6dc68da5b3fdfd3c3ec2cb6953ec8ae1525104242e112ca16ac96c5fc34b9a75348f201fae78e68a039792e0a752218f88d3b84b36d2f4de4d49c2bdb4da09672a892002192f891186b2e98b7aa1cd911265ebd75922e22f5dcdaafd1db42ee0b1a9a6d99a331b1f91fa2ec488dec249b85c73fee8a23cc4643caf3eb89ce00b6beb46fee6c8a4c41ac66ec241afa40b04636958004b20038c3f9a0c838968f9ad22cdc54829b885495510e0b0677d6508edcac4e67a87a2c41e7a16d86daf09617151d675a2e169d0f5a8c813096914e593a446dd6df80bf849ca428fe4c691a46e8d5f1c462041ccbcb87d050fd296e31d41d4e40f690850cf720aa6be7e64333fa4b86e20ab932f3955666531c59f2f8b14fe5554f8d8d714479d90cf7813ba77dc07468c057d98bc2cff9a6c0771765af08b8278b3b5a7255e3d1c652cbdd2f9e3d223afb04c8af748436f8240fc8e167b2e28c09eb47dede91e5b2366c8e31d35298d4b84ad88fc60839b1f6e9e92e2b05218a51ae247240040aad9da48e3a7d234bf9d9fe44f0d4d8e80b052bfbf8dd57c08130fa25990e2fc3645d7ef6b16928d969f5e6383fe29691bb215e14b81f8509a5618021b64c4beb33bc6c629be77a2ca50a8c836a4b5e6be27f512b8a49058e01afd7ad5c1e8547cecab8b05dbfaf2081a191344e8ac2775512f01008615afb07e12dd0300ef629c9f170a540698c5d85f73928195d9f62a999e8f6ca23b583f262f4c3fc9cfc6ebafabc999eef1cff15aca5827bd877b42d7b73e95062372fdec58063d0aaaa5965c20a540b4ca61f5d5d39f5a5da5b09449ecbb64b610af07e38c325d4cca2f27a99d13f9bcbe4b8b16761ae476c5e0e447e32a9781375b64707098635320d5878fb9be9a6ec5d26bc60d4777b05fe580ac9ad0ed911ccdc9bb4c3ef35a9696f053f00324b9bea59c413a358bda0556a552e76b67e394b81196658a0fd580d48fc9bb86e7e849924a1de510468acd849424fdd275d68bb4697ac86f130dbe861c5ca83ded5dd82904bcae0c3a924174ebe138ec84ced6d754792e186b49b6f2e7ec01abcf54c92c34bc2c174976ddcb2be15f74dcfd2d1f4123fc23ab467b56c28e7663ea6d7b410427e481c93255ac636c7c980ca15ff19c6d8bb220ecdf396289db842aa8277dfcaac713262acaf14a0a6160faa29eb9f39f6485bbc09930a584c005824257b934c37295169736cb77b9b8f2aff2ce116c2e8578b4cc439608bf2db341dad14b8e1346d8409c77f92ef12000eb680c768d3db8b7421fe3260f57a7303556167ea97ebcae48d715e6d64c07e1e459b8f9b42e103266966f915aee19140a826939591546d83d92fed06cf8acdb6c7df6d46a58a2e22c97ef5cae69d35f0527cabb05e857418212739b8389069303650348c64b16154d4d0f46aa4c1c47046c634cb7a624e4c9ee981f7ef03a55a2e627484287ebea1840f834d903b2a4a3d8302e5b73d457e198ba9b1bbec4dbfbd8b8c7b1b707210146b9fd4dd710114b6ca6fea5db31a4539c24817e047aaa343d697e4f4a864f4791b012b0bd24674cec4fc2b4edf9e6ee41da9ddaa6a0995bae1f8677a8aeaec0bd55c8eb9f934d8e28e30bb91297ea098efe8fea5648e7a95d000f5b03e23f4889c17a59b383bb571b3417636a1970f1dc23ec72424ab2984e621034b98e59fe334ae77ecb8b4a34cb7a5a08a9ec19c7832dc5b8bac9e0869510ccda533e96a5d80435c94bc7897ed7df31846be2b900b5f5040f2528192d5c435ae0a64c05a398c76e8afa9e90c940e483b7d9ed5e3192d723cb5aa1f251ccbf26c95427fd94cbfa4a2663f03f0864d3829537d53a684a231ec18abae1d9f45add1305f4ec8f07cad57e8bb2f5361879e42ea12d272cf47d6f60bfde906aef1963bb3cd58126aa7723a4224c4b368ba38d932aea75236777c11ff86888065ed5eab9a18e8ee230b3bfb8a645438d2275d8306f17079a6b158b154a35934d016ca4e6db1528ec55a2ba89fa60f267e8936d6f07af968a9c0cd3d2177aa20e73684cd56815b102d89a2bbaf12765cd2a3c97081411a049d80ce24cf24a8e81c68e2d60f36d1f577857be29d7a097575c2bd0b4d6f6f1e2f063f6e76fee8c866babb0f0dc51b0f244b13416b8d291d0093e71d1ea420805688f2ad73a1dc55ab5992bd810a2d155a619be8fcec43a7ac504a8fb37d48e570a86b8b2e0c425061669f7e93efe8c21d0d1e549a9718e6788b08a4e910e4c6cc551bca7f7b8e5dec016c0eba18427e3b560dab0428db569b699aa20d5f5e291e9fe532c4c1c6dbe005f719d5477e9d6ec81ffede2cf9c02cf85852f2ca2a1d4607a193112d96b2e8f01c0af6052ea26b1df88f8e6cff52e4d1893f1c98f56eae2141e7a376e02d74c082466e6ae2e4261145e541f3ede2dd804f4e216e53afde217cd2932262c06c15840555dcd9f431e231ee669ed610a7b8001f68dcabc615dbbedf104feab2939374af1cd999c75e92b4ccd2a5e8df22ec1164f539ba4916112de70ff1cd7e4b504fc4649a0d45f49fd50896848a0bc3e92b0f0bd202fc9d7c2f67c9f2f0db3ac86ee52249f92b273170443c74357f76eec932dfe924c2414f07343a4fbeced8ae612038e10e2a65ba2c9531fb956ee590bab82c594137cdc9b2d47e5b45eb887630238c8f2649326f8071aa70550a897b0a021069b58cd0935b4c62cdb6c286595a40149676bc6629cc7ea777f73ccacaf9b7580fffa5e1442c39e1bd9e1dd8e759767e25bb6e4d5213a89bab39d7c62c4b171e283cd08147458df57c08a9baad202fbad2d133421d3cb3405334926f6b1b327be5e556ef77f122bc1d056526ac8fb1c314fe2be3d654f56f7f18c510943f62a3e71d274d8ffb18a5033250484bbafb9eb5ab2085906b0c7a56201724b37a2865d906d44376c4dcbb43365ea50213201c9714e4a3b3280fc02fa3afebbbb1d3aef66156e13f1e37c13fb3f77146ed2e519f2669b77b56729d6e32ad49d077050ce1afb9b6fe98743869cbc4cbd879382c9801fdd506bada263a63205fd4d275bac32dc7961584c45785cf53204b88723bf4d0ce9510accf9c12d02de333a904741e30476a074959d3c2edc9c7e54c15a2fa6cf66f40db933143920aca3f18f09d684ad64242f6c76e02bbaba7552b3b8b15f48be064fcf40c830558d7409f3b18a942e26834c88861d25ad0fcc58fc55a41fc76f5e142f9d50097e4de2e6fcdd6ce31759529fe4df5abfe15b2f2f19a40f5ab2f357b2a65d162a9347c456421eff6dec2c18958fb50e897daddb0678e62273b7245ce3bb43f7e7146a2ab0a85f4e67398391d875e0915260c3306524aa23ca74ddce6e185ca8cf52e7b614182b2b50916c0c4c72f2593b8f3671251e594c4652dcbe9df6a801f40e422083d4a189678ee0c1cdb69296e093252e78ce9d22679971c4c1faa52dbd7cd60b5f899bc75f393588b1f1a8978c51fa90a836c69633a9eb95483bd1c6b4539052375fc734817664595e606e610af1d31afb39967263213b8db29c255274aac2d12aedca7bb13000a811dffc2373c3dba0b1fdd9d020341c14d731b397ad0bbf9de8cce2a601dae6e30fd43c3fff00a4625604e66a0ad6a41cbe58511b455c45cc8ab981bea3db81d616368c1fdd66e9205e8e78c65ac1f08b5c298d11848db08a4d89709a54836120148c437d19d5ecba291a32c99d38ced2f0c21aff070c503c941b7052131a386cbe0553b821f5b83c70610ce9d07aae709a162435a4f68b508f02295babdcc52858aaf11520d43de8a86f21a09dbf4646e2a3ed5eb9cf175986f3dc874c545e17dfcefb0d05c6a3d31142d9fed153547866287c40da451a7bd0b68a59255a0723258f642d1b72aa9ce919077b90e74e20a0adfee94ed3ba0a10c90defb16d8ba411e5b33476aeb38b6d23e952d10a131a8318258ae7238697094d7ab3e757b0ea05a009fe9a0cf7b0dc7a7149ed1280d5e1289ba00dd82f02844674261e6098973e346614e3270304519cedc67224ce241d626176ac21b59f24ad4dfdf9f9cb3fd4b66d24f57501fdc84ad84948d653bdf1655c160d0539197b3e49dde35ee01cf818c020f1a3d8f5f2ba7f66c16511472b7ebde024550da3d39fd5d34860d2dca362123048c4236c345e1e77e2e3eecb23d2767947b8f2c4d4c58d5fa8f81d67a1ec41634fa93218210285c5d0bd48f616b4fd2f1b85d05dc8b811b130137360e6adb1cef79ec3e51b214734aba7edcb0d7ecbae829429ed59c6c48fdf6dd14a2d566c5f7defe9c6d9061db7fbfc80cbe8dd00ea012e4247143096a74c8c88eb7cea5147b93e2e3fba114dafeed4cfe36a8783846871db8ffaf8f00c2ddee78a9673e8d6c5136829feca9c7a6a3daa42952dcf7270980516fae91cad506e42e386ba4a5f9f1633f67c82e85f6459e4d81f2685499f10e4ff4d2cbd56533ffc476a9a8c38f0f19d6c16cc4cd21d14d280ac986df8ac5fb2d8095bea72bb26beb7d68358288a14e575ed9c28d51e96ab7a593b887a30b5880957a502ed8990e3dd0049191435c8f5cb350b780bfb5b8650f4006c4ff43ab6428a1d8f5fc2ba99f2f55ea2bf03f0c0abc75ee34ba4d9f24def06a9094445361978c5a60bbb22778f4554b4e9f6750774e96195ae924602c352e939626cc1555564531622e22c5b1a468606d10af8a7263fe3d61ac5fd1987f57e263e8be91f546358e06c0655d06cdb567621c66b9fb273870edb7a7e8b44eaf14894cdce00c99ca4936ba849051c0d7bb883e8abe220a848ccb577fcc48b4a626504c887394f9ce487eab6bb030b815963f4684ce7de94f47774781e12154ae3e9a916957bd263ea403b1486a1ee5752d1d103fe7e761692db10eb77582bd4d4681e37391d25cf1c6984e447b59c0b71cf2125e155ea00e92465afdfb07a0f4040be4888d63af05974241c5418974bdf9c9ce2d5489f7cfb7de121157e87a0df7dad1a8b62fc02224a6633fc90dcb6c2ae9fdebadbdef9589324d6e504f1f710a9fd214fe6518f420f8869b7beddd3fbf2bd116c2faeacffaeb72c74bcf51c9ba42313fed1bde166345c7cc4db51e690393446c4e9e2aa94dc6316cdd17d6b04ecbb68d6fc9acf9f113ca32832f386c6b44919f410d1e1df004d7e760372898a9cc01ebb9ce28e5148c80f415bca9d4c684c282eb4d954af81d94a4448f7fb182602e2a02d724f175c5a826f2e6c134127424bbbab10f7a75beb8972d1937014532b730edd9ca85dd37c7093cf25cff1db24c415a85056a1e5e6c9745159380810d606905a53668e2ce87da480a4c2c249c6c57085ae11e0b98b2405e3659e88a64a8815caa96696f227f00ec2c38aaffc9be81f5a5896d5c941ce760548e581ca2ab2558edd88df218f1cabbc72527bb4742516791f3f1ab42ec4bddd1fe0059ff2ccd1a9dfc7a0caf2391ad57317ca101f27ff1f9c4963a95e22e7d2f5152194297a3287f0301a06beca384746dba8ce4b48a87ee1929b94a6de232b97e9d7db34a2883ceec527266be1c453420144c13e5af055c03b8b5d9984914d70c8e5c9b8c0c72837352fda76328f5ba64e161b1a21cbd43e5fa1e96f000360ff406cf81150641cbbb9d6f394fe1debd0872acfc1e18adbf32e959b66e9fb9fd13dbd67416610dab5ec27139456a37efccdf7e44a8c2c6bff2ddfa1c0f06a2cd53c398642db23fc8c4b88d2f5bd6a43bbae2365ccee1d60c0e4b8e9afab19c6d00a0f41b4742c6e01a040777b6075efbd90397c9e5ba16ba401bdf5a342c53acaf29d4cb47d5a1f07260d578f1e0e3245f334a1be1078296e7cab8dc8e8ca7c6b31b245f3191b68e2e44c31063efc5f67108d39cb0135fd06efd67380f45ddf32b82c7f5dc58b9ffe68fc446151191134d3011cdcad50db852d0a37a696dd61b8f04d52ca14bb5ed4f8b5250e351b9df06088da5650d600438a5cc2d1b4daeb13bbcfcc9fbb319adc7cbbfda830ed08f7b09a55d729aebb48a8e28af80884b2a9020423b507a112d785a5069bd0535251122f09eb517b16dc699f35bb3e75bf42b9ff3e1c7297918f11da72752bc3d594d9a3f21e9d66852cc9a75243b8516887e25a7064799f3d596b72cfd17ecd954f1cc683813756b8b61ead2b9bad955d0d4f4535403f93d7aa00de947ad9b10d0bca78a2e0f1016a4244b320c9b9ff568387016e1890de6917b82c19f9c4723efb2e761c864a51e7f3d2bb2a61e6a53ee7ce5642cf3a6073e0da8fbb41f26d9f7d0699259c283ee3d9ed9c16a2eb7ee72b6c9e4c21422b2d7f7cb6ec4dd400eceda4bfa5cc0962736b988f729be743bd96dfba7e250714cef011193816c8f79caf1abbcca016b249e350175b72974bbe6536092d21fba6f2a88d8dbd719a97eeef77ba9669b847bb6df559fb35d59401e7f373779def099e75269550635f741a0423bc452ff42e018f7e03f3fdebe7a3bc053680479684b3a2abe1f5c69d593a3d87994d82edbd7bb92a66458ad13d587d4ae10cff073ae5fa1b4d963fcc7aa54a86c2059f2e88f5774488aca246d9fabd674b1af7a07fd22f8697a2deb0900fd90f768be104fc0e80ddff50b2b4860344aef9829950e319cc2afcbc7da3840d6bdafa00a155f45a7f18e678ae5c06cdcbdf291706a563cd12d9cbc7e07d5668d3407e79f6fbb9026133b23b154e80d8196dc24179cb9a3d6f0cf6b0095790361b6b73120f0a6a1141ba5f01407c564da64a2ea2c8774726fdf1ac1a648aebd8d83186dc3ce6998d5fac685fe9ea410b0fff6dd85841a1a9b5fdd09f60592c76985c5578a1ea0e52b31d3383fa0b34474a688742b03238b3b9ce14c83ab09c67ee96a634f48942c3c8d25447a464a6b47fadcc6b8550dd135f3e2e4e83eeb4760dfcb4f60143110ea50bd3a4cda10d3f74b3657b082d12743f75ab0d7592ecbdf1e720a68b58b0b414f766d72038c2e502e2d0a615e737e0ec04fbd1d7c04100b87125bcd01471b19e4a317e3f7be69f39da9537835e8d1079fe438db04cfd2d17c685eb41448efbb833d5d931b65e2b813395af5ba389bb12d926c9e9845aba69885ff863ecb4883934f82e6d9d192f6a3063f2eb8d808554cf63aebfb8977a59cff541ff6503bc379755ee672b90964b11dbb060e9ecf5259253bba6730fe2a0b79ebb63dd85c46d2f7e54bc99bed87d5467ee2977b6a5729f9371d89e64b7c930dfef945c9dc0fbabcd33020903c65be85a162bf6661602f3e6dbbb53816870d84539765b89fcf9b5fd135100b5318454993d9024b3602d07c9640d1db58dd191736cc58579eaebd8ef3e818c874d90111272f92adb455b9acfbe047a0a606511d37d61377ce1ca63c7675f9a97ecfe7c3b0be37dcea0658affeaaddd904466ee2d82eb443862fd626b59de25eb19ec7e37fe0ad5bc682f4cac52b80026757ac3d8c7fcd1cb2403ad58bdfc877897c947aa273fc8758ca369cfdeb3badb88450586df3193d316d308338a58ac35548bb2e1309c7db29346a7a69197bf24495fc0a11f692f110b1c7d45a1442264940d551108e11841da9249445433b1d44432f4e26dc27b2e6f0288a1f79d27a860028f7f8e7bd4036be9e68eff3fc5e20380506812342ca293fa335b18b5c81aaf85d4b3a6e97e7462b8a3becb125b6ca78718caf48dae5d1e6f0819d8ac784f7a3c8f20a444364e187ca106d4fd68eb9f0535c203d70141b6067352426e167f876fbc3b786808cf7c17cae910deb07f0f3b83633e3ef70771d422090ed6f35b94ebda476f4a418898683063b63a89ffad6be0a826af8fce6515020d73d535016b8c7d6d7169d8fe30c2c01ad12e9536e4333feaa1f3b031a5b8b654b9bc27440ee67264e2b9eb3348272a13fd840b1c1853a78800dae74099ea2d05e117d75b9d536a22a65632214005bc5686ce51d747791864fbc813bf3f1aa73751e1084fd7f8473a289cc7851a783972c5e20d5078980294b7f880113dc01aa49e7ef0ae4650c9ac9049301721f3093f3be1be8716b20f7eb7361ccbfde4807a180ae745a84cb2ba4294e9b59925ec17fbe1ef51943f2c92417f35cd3be8f6c69010c73302832b29231777d98bbccd364a35617d4f425e4a7f35ab8d1226a7f7fffe3fc3fd052cea6f9f9e57d853ed1aa9602c4128ea39a9e5d7bbc4d857d48a9ecd16911d833c859eac84f32ad9d37e49021145da9dd8538395c219fffb10cc15fb7c3866b33b89bee03f13b57737817b225413eaf046e641d949aa74f6b9cd3ffcb028ba8f0d2f1e2051c21e942fd5f702359bd56c87caab191486182dc2f81b37b0a4ba02961a0f489cff41dc057d58ac364fcc363fe3c85be99f9f6c1453710e5bb14b8716659ba5d021b475ae908237a1a4f96778349ccc69583ec4cd8d427cb66ef18631b34c4b2152400a09ecf103b7e1f68344b7d5c783123c90d6717759505c65d882bce698c093e2e352c5486fde958002358f5ac55aad17e3eeb55276794835f572e894840b98735ba7d9ccae8c5e59c74e5149feb7afb20375be957faaa7075ab56a856ee61ea1a68375a6da8b8130229a5e15789ee732e521459b8cfc1540c0e835039996c675ccee0d4de4804950dfb4f65bae5824629cf06994e19a543b7306a546ae7fed963a2a5cba8b5f812170cf25e9d7da0b7841c00890a890e01133c6a3b27bf8ebc5a55597dd1a5c85e4ae3ac99b5de46e12bab3a4661a2a0a6a8108a2f8f01ca8a82410b59e1fd2d5a49543f2a2611c3db2d58486857e4f07e22ac50420c07b6b341af8f880d101b2819744176aa412535e8430602e7a9b1a5cca2635356d9b1b208f746e9748de27e53018618d2b91aa123f3ea00b54660421c201a3efe80ee43ebd2a5f7f98955eefc6af9baf8fce24e82aef30ce44dddfd305b5578b3d1b9ed22c840d547ec284d3eaab839d2757997a440c2f240d4caf8d074ae8f5bbadf1871a9bdfbdd6785bbd24da4791d0901e5bc280e659142c8016dda115f0de29401a7c52f281e66d6101ad873a49608f162eef62a4145085561f55ee9cbc87c3214fbfc4f8e07461d4ed489dfbf92bc0d16f93b814fbfaf416e603acfc43d09b424c4592f16131c1051a2d166aa286282db1c5950caabe6ecaadca8890726fd865af2ac8ea6d9dd580d4d7c913a9a2693b8076ed70333c31c286a0ccd5b76cdc137688fc00a87e6fb17c02837e92a9712044096d1f169303d2e68bdbdcc3c7d083983b18532156fc55e0b5d331e7070d83323781b5cfb8f697015a9ad8c0ee3580273ea865530881628b55da35a3740ddc40134973de3cc2887b01338bfc78ca806b3eb44c7953dab469406bff16fb2dbd6900ac99c8fb25f60e6d0ca55af023376c2219eea20b6eb6f150435b6c08eefda7c1eaab2636f2564d1044b7de06aff3076c3012d82a7a43555b4074154bd0d30e2e643f3aef6a19f5c8358f194d67e4941c21f27a0730ee5d716c1289dcaeddc234411890e5ea6f93ec2542f456887e9d1143e759a1b2d8694b5d456b2a7211ee43fdd75d087e21c08ee6e489d9d571f8726ebbb221524ebb9bacf79a026a16d99bc209c4a427db3beeaef27689a0e5c6b671e2c10caca50470338d6f25092aa7c5f845fcc7e7f815be93b55efd1a29f9b8effbe48daef283179497f2d18be0718500469446611bd0f97745484d6b757f293361f4eb4a18123b4819e16be573d431bf500a0b334042b8e60c49828e00cc030486370a363b15b9f0996830d7834e82232b0537f030f83d2fd6bb95a79b3b926a5adab10d7412e65a0704381cde5bb0be299e3b78bc10ae6d75cb9bd309c3ef98f5dfc7e83daa8becdd5073475790f85497c175400e03f8f27a4fc87a5d7de8d966e83401ce8b38111141473f77a47c2d3237eca4c6905eb6fefa5e1476563f27afbabaf5bcec130274589821178e24bdef9b5165246dbc4c8472425df46b3f1dea61cfbe1ea0946337a1ff747c1dd5f15f609c5843c0fed469a41562f265d7c30c2659ae300e3554ebc144821db14851e53807ef5831af814cd011e7e17022da938e308a57af5b6970d2957e9c89fae0e91a1666cc52666ba8dbeea5e6dd56e6d7cf054b9e1d11ca8111374f92e7c0d26d732094819f9b6323542fda7d7fb404943b8e5e25c6e6087c18bae860b211851d1cd57c481cd94b81c1bc0da085bb751f173cbff794471c8bd3ff4756e2d524cf5dea994a3bf693e202bea926a13e730d2cdda0bbc2f9e773770f892064c65b6dd2b686e7a0676d486d768b16f137a890f1e41cda0e7c1f09e927ea35c4d9585a8ff52cb1220d7d6eed198bd871a1b2eab31661d26e27d4accc8db042359a0dd399c3c4456cb3a3cadf14e2bcfa84406ca154daf2c84a6c402d37c07121b9756c263225503135db28e56425451e99297a0e2f9c17842ff8b62b4d12fc056bf233258672e14ebb47ef08752090ba6d32f465cf1fd7439bfe84501b8a4b618a268aeef22df4e32115a1793b4a76f27693df6521d67fc2a1015fd8d06940cbefbae106a2a0f33b1a88e9411497e872ec69855f9464d8a651b6b46ef7007f745b6347e640eb6d759a5a0a43b88a9f704b2742d671576af26da10005841e268475fb021d27a50dea0a43c14a6535e1ed9e263776ec2f749172bccd4583b57a37e4e1784d17821a116d69af580d316495c2f470e78549b334c56d93601bacda4d32e0ae8f4c1ca4955ea230aff2316f3107dd47222767dfeaf050fe9fb1691b58a88ee00fdd2b5f705a639423eda04f846e23b68f6a19eae5c7f1b6ec1cdca9276a77c6c10955e39e65930ffd10a2067bde90600d87d7c93dddb12b53d9e701b6d92c9a03c24c04feed95f551778672c2e57eacfc3e23eaec67edd4322213bb4ea273df8da5ab2405018f2bf18ffe03005cb3d42c805aee9f8f64791faa7bde6812c7494c9ec02c00cb9b722037c9143d7f9a2aafdc98cf2fb0438ee3be5940933a53168a48772e2f8cd4dd477897df55a3b3679b99772cd22630db499952d734f4d22ba67fb5609b690031c89580ec2644eef835347bc3ccd3701ffa2384cfaba1b693ebf403ece6cfd37f56169657a33d16169d5a750da4afbaffd9afa4f3c210339fab140b45b4a91583a18c78738571de8165447caa1e496265f223165b9f751a90dbee3f634568cee74c2b41b76709f0bb1dd51b76dc7f11859a0690a7a87c53a41d1835bc8ce34cf2dc7482b5b0011af3f628cea5503d5c2cd151587a891f4810bf3000e1adcf61427d4b9ebb2f0671007b94f92e50570a53c4ecc8e6ef45af2ecfa7c2b629f0102a1b12f9619ccc235adc73758c656252b1e8bb48a8ce956924824255fd08c5ac036fd3bab0fd0c952a9a5adc615476811d55766b9f09ddb1cabe93131ff9977b9cbfbdea0a99fbdd300cdbda75945c9c4f6dae8dd7738b167076a65fa9581cec75abfc283474ac648bf23bda66e788dd6d39a064a9a7885c964a09110852134f3ecc2f28d08351b9c84e3fdaf69ccfce2ae500426eef50a6e49f6915c9560134ce8144a114787db7338fb1ec54fd7f2c10458d3a086e3921ca38040092cb695f0a3813d4c6ef676dde039307346c3d532aca83f2b0c55d6cef9e0b65d5c480f66c531c12b270bebedbd4bf0b8eb9bf17aef2488ee35e9927804c660ba44abc70a4f8c322722fae27d12a85ef79f8744ac6341092bc74c1b9d0cfedffcd942b0577feaf5398295d8dd9f7e252ad2d7178fca52f440e1dc20f37916d5cd1b4ee20cf2ebae2fbe55347ef201df735decacbeba05dd37798dd9cd538090d3717912235a0b2d9686fbf457d7867229f2a4d9c2a747f80f01738552f266cf3f3cc9d87da146aec8eb5c840d859ce0e1bd605b752b0230d56b35f19026067bd0c8de3f4e5907abb7e4c6d375574e89f07515bed885a6db2741c2292c297c5920153122f510587501db0b4ea16c10bd4d1f42d97abf1938067b3aae58dd50b735cd6f897004c25f8db31bab2632008fbf9a3fb295d03951b772e5a1e837413d4cb0b42c4352206d834a57d32b808644e3b4ed0f5de6718e9528909d817212a8114c6166ad5fe006669b103fab742d74f72376127098790a00ce4e7a76abb9427c0e2e7c8c53aebb8600dea898113986d11373de45aa0e2b7030820b1b3eca329fd23939ef3fec555619e27fe17ff155abf36f7108c26ec8201f31995c9fd88f0ca9611e3ae95ad7135365c8d1d15bb004164d1ae714a31e1a38023dae9684554a79ea24d6d28e73595305a27449dae80b45ed482eab3ae0aa44487f3cdc50cf7ec2d4d252bdb82890602c6d3464ce320732b792fc04420031ae848f47914d7c1a67908fcb59c6c4e6d0bbbb044fe52d11635a2eb400b9c47cbe73596a81ef191d7124c1d4947d76899302d3b68967671e26d61d571fe7bc722174be964b3eef3d3bc9145af02a52f6590f8c9153008bdc48d16b0a4e0adc02393c7ff6d69166f2a220063f5063efdf9ee7f54ce84d263a5add7bf73358095e79fa60b82adcab94b15ec98fcdb62fb55fe8108392453df7db1b9cb4dc50a5e8b3f913e0b5856bbae9175ab8323971936dc21e6a006b06f4e330dd909b865c402306010849c927773a87e8751bed872e46bf095be8061f46976164d1dd56850c9da87803bae92001e0d264707850c2423945340f87043db5a6216b147a5b4ff0e85c4706aeff3b44cacf697dd6bb4b0c096d1fa273bc9915a7fb929f80d0e66a630a54e3a23ef7155184f737e8f07fb231d23ea7c3fea11d0e591943bd252db269207c3984185760ba11b0b1c3035c5abb02b6838e1a8e70eadd3059cd6bbf6a92a59c35f7cda5b7f427c4e1114542c187d7eb4a3c1f0c60d6757885f8b673b0da15a3a0594c98de16e482de6181b1af5047900980a157164eec81fd4d7f329cd34f0a7534736fbfd8672bfe8e5d06cb9810eaa52c9fb9b13173d100970322ebb63a647207e0421fe9e78ded108ff01e67868e7bd1320b2b0ff11c89e23548cfb5a624fae92cd34664ae47f877c8f754b55d8f93be0b68ae35c70125b5cae8a0b6e547ff1052f2930c8976912e967f57ef9aaf9dd4d0bded21937a547e1f5efb51ab0108edd2417e2c5383d74af78a644c2b35fe655549dd96aaf6336318e4d27c87ae57e48923d2b7d367745ab98a5797e8e99dd6651ec35a81a9d940253d18b7f0ffb74fa3d1df4edc4c1e4ff4e3573bf5423ef873cc54fed5bd08ced8febd03b6a64e24e527bfbe43a761bfb687df87cf6b969cdf57e948ea585f09912bab4927ddffcbac731816526782dcaff6554922d27298ffa9916ee144a01ace0a11d538b159baf45b893b5855f2112daba4cbc0e4516e07f56e3e355d7017a9050c91c10381b4fe6be2550784717587c4001069729d4641ec7d2fca4e7cc72f11ef8f701d2de5c337d7f803f16c9b465a4304ff4da6e80a3b1ee028537f55d368a4e9aeb07dfa5ed5ed70e994055a2db9673e07af90be0c7adc13253770daeea04af4797be2641e9a17255e9622ecf4fb435cfa64d28257181b03442c446e2f9395efa55c4e3c5242c8b752a3a1387681e1da89dcd7ba7708e2689c6e9bd642f2481b2c43dfedef6d43b91dccadf8a6eb6bd9e3ec774ee3a25b99d485d166d043c0b3eeaa3c32469761dbd478ba2b63c58640e843286f40a67b165884a547d7f5830a0fea6a39a87da2a5f562913f8fb9674588b191ade8bc3a09796cf530d60ce237274ee20970c7a6b84cdf05e43c1b914d0a7f84828e4f6ebee9de09166e8f7050ca01f940bd3509b4c30eeb0517dc18fb425532a0f42dd7c09d33f4ca07f6163c1346cdfa33045564259d46a3084b69a9a561720b16d2626165d229a983bb25444acdf2105a9ce9ecff4b353f1689ebcfbc46d0fc79b78d668d9ce296c010a724f30104a528238b08296b5e3bdc9b027057cad48155dabee06064e5f5f48046e5422284a2dacbb4cac6de1d8a2e98b0fc9279dd2413001d2ae8620052c182e81e4150a181f066a502a7db89070d60152dc6880d8166c08d5a5fa3352810e0f3d70a2102e3112c8cd520c6dfded04921356c29da775097a62feba6bf72ce27d6716c222a84f57ad14a00639ad740e859ed2c377f6cb0b05ce7b800cad259b1f605e8ea5654b11b454eb559bb7c878cfc28a1ff870ac2dd3a4f5179ee47b1b3b1383ca85b3a00608442f8fbf26f61abd70c9a318f16f8c35a8647b65e1d42747309d7671e414cf0311595472a49e753c1798e12b5c01bf3e1296d19afe7bb7203ef71a1e5834c75c195132c11377918f0b96c316087f491eb19991a88863b13ee9cdfb3f2075803a1f5a54dae4f9709ddacb25dd4c564024a48bd17489dc448cbc242a6dee00ba0dbcd8b21284be2e00571923329c2339d1584226e64599109e5ed33252222ef479ea4e3d27c5e75bf581389f1ad2284a64f8a8a6a93b2e8b5f3e7aff3ecbcba4f8cc2c09c40e04c05c349a4990043ae52949cf481ca75e21ddf88a9db9f03e93b4edf537d98df5ecc4dd492285c7c75633d71aa386f7a18f755d31916cdbc4d23cf9bf2c29a2d6d6f6d2a32049ab3a9dc9456286dcb88b257e02016e71aeb836714aa184bbcb6288145b05adb86946e32bb7225f03265cf7f10e379d5fb52ad3429d2c089d856bf0672a93175d60b349bde2afc4413817a126404b77b20a1ca949e3ff56cbf1f9fd8acbdab6c9c7ec91b368bc92937cf0713fbc3b4d55932d5c623a58f8c8c6df0a4cd0f9ac7be1893be51bd8fe7496b44ff12b6ab72d647f102d9fea5d27b268cdb5e0aac9d801d33dd2fc83d35cc5706f2ac7551ac39ca88546f9b5fa9fe92a5fa957c656cfe4333632194e762a4071dc8fcce496dabcec65c1b8668bb222aa0adfa7a0b8a709ebe522f8aeb96c72c04291cb436fe2122220614fa1ceed9001d5ea5668f66ce28e3dc68c75d92127c1221ded1a72d816da5d5b1da07c165f10249b27b57144e7503d4b7ff0941317265d4b7ac48c449daab7884e5b7073fb2db32b14e91acd861cb5185d3e50b316f98fa307bee83069e12fc8b96af3b45ee8cf135e79c43ced217f594c56c5a1894ae18ac9c616b6a56f2fd2dcfefb06d956b3a16d155e8a0e73d03e1d00268612066e9239cbb49ffc90e72d0718bb6ca3977e3bd9cff445844e2ba9a666c3e53813b48a7ee659a5b6d394b8eb4b1d0cd24d0c7a4678d3c25fc98db03788553ab06a8c99043f1224c6481fbb72202563d53a314fbd12ea6847c15de96c84a07d574083e0bbc45121db088a72eff5f977f96f7d517f879bd8d142e34799c7cade0a0479ded2c3dc3403255997af35cb28912d8d5086c955a2e1889fec479ed9576ddb0dcd1cc1551eff86dabde21326c9b7cd45b82a15030fb518093380853a687843b868a389d207c150e1a3e255f7b0846951f8f338a7338eacfb887917b6c71b506bebfadc8575146470c25923ad8af738ec629c81e6561bd567a81b99f15bd7f47e7ce0805464146cd669e0fbb9e7a8a47a2566350922b9257946ad75eb7925667bb2f264b22bb3d3ec6accd184f6b62056706558ac3a3d2fc37e5ef1ef16d87b8e22fc1f33505116113fdf8e27ae01e38eae0a4015bc2829cd77c0ca33daae9afd34be91e316b9e9fd3f0031985431874ea65680708755cd2514999d8a9e0dc39c268e3bb9c0a444075fcfab7ee89d351fe49b4e31bb7a3e05dddb403cb1d18e0b306196c1db3390699be56f175231332577d1f18f687742a132046c92f0819e2c12f58c8d6d90cd9fdd223c2222096b67d18d3d6eab649ae62a9432474c79a0b58ec6bb1727a480e88ec13b0c435b27b516212a4bcfafa64230f2a93ec997d366289876eab9974e702be31749a95d5e9df791230499af07f076e188f8a2540dd6df3ea7d83caf4988730d293e32a7a779bedbda9c5f5d21e9e23be8845d74a07c1349c4b338d658ba1d5f49581dbfb7ea36e866825ccb591ad1ae40cde1590eefbc7eeaa2f4232f7a767fa9ea60afe002cbbe7a5e6127f609f1f33ccf3aa14bcb6dd5b60b059900aec0c820e4cb34041bc45b93acba153fed3ea32112179af2bca7909fba8372916492f679ae2c5c37d658ea5580f02cd4c01b7defd34a1ee08df33b4d5805314ea2e9762eab82888d144dbce01f1229baa7eb7b415ea2e6b8907f4f2c860eee274380d825245401ba656185bd621db8be0ce7a157ad1026791583a625ebc58b9a3a1cf266dd63c9be70f05856a2f410c96c40e51c4fccda695ae0cf7a32a56bdb11a6b112d53fd1943af4764cf8a6d645c807af6d33d7a338a9e9db8a488eb94b42141ca3a274e4f98ce604b9aa81d10c7ad64fca165fb3bff1002945d5685cf2cdd727f07d40f470cf9ac007fd157acce6a652242bc29116f9d98458118eca289eb5a668527727968ffed76ae3dd664fd35e98e18b6e16253f4cbd911800cd7eaa454e151e3ba0ec830c64e29603470f84d75a70718ca3fc7d6d61568ec9dc12c584c471e6529239bc2b145da2856dcf89813a2676207e9c7ac0e814f0d78e1656f98d41794f03b79b77f67865cb40823128bf03452ce2eacd2f664c71929ecbe75c3aedd018a4c1fc0e75e19fa3b5dc6d89f7a384adbec8e0e548003a8757eb380356fa41ef91380cf1fd9102d9b7933f3a3b563355910238d7e4890bcc64f6150ee3fd40cb46b9eade269c3e62f11f51d999c3424195cfb6eef8636d609c1708aa2c8313d04b16868b6597c18be7d67e4d378e2accc3fd0b8c8e925ef788d7b1edb74af82fd8905a4fceb7662f2c3951f7acfb8ccfc852a93622de8bfa4fa775e967143b2f06191ee70da0be46c5dcb54f1aa709dcabdb307aa8ee2e5f805327b9dcca3236b3146dd3bbb84e34f8cc590fee0f59fc8b8a39ae1d4419e1e42cbc422f0f74777e76fc7f8e75f8758bb5889259e0607988c20dcd87cfa575eb374334093a9ea0dc7b320c2a8c3cb548b28170d5ddf5bc436819c015226150aecaa83751c8a17d0e69772a7f2567012428c25974dcb228430b0b39fd5134bf3a28ad4a13453e81aa3dddc71f3a724d7bb603a5fbb27c3ec9f9388c61789f9cb434b6eede110ae1c96d78cced02cd3bc2cfff12856131ce7ccd3035f60a0ed932bcfb40c5dd28d68621af44cc270163df4edc11a699aecd93448c46252d272193b5006b7c88c34db880da88b51ac285e9391d7b611caa7f1867edcfb2a77c63dc87e4342963ee7f3208d2b0369ca71b2681b7f15845b307e3c5a75e3b06952b75aaa088ea475cadf5883788ab5f0dab68a213db45df89a141fc29c216c1e8d59c4eba657db5d862812e543e722baf7939c107455068e7b542e62db161950ba876ea3f8e3dabc1047cafdc20328e5e8df66e0202ca6bc1c79606c30518f68c90684f55f7925188259a2e2e12f289e8396808ada47d4df5f6e1c74220c1e6c09865f9c2f16b94dd1af694ca80be4d9406546d57eb611465e467df2e0454e566a929b8b9e2ed412c7c526780aa8b297d6953a8d680bf4f9c055876ad9461b82e9308c6afa19392702e98e0bb2a12fe1eed37a332e522cb6a0bc8fd5178daafe0952b138a3da18d3a5e35497ba6832a24256ccbddc83bac0db4c0fdf40e090d54ca39f7e25c62329155cb92ee93bb54f0e67db9407c14db715bee209f8378638624ba31e85725c929b49785d7767d14a238b3057d39a4dda55d46a27caa3d94597996552877e79fbf9a8108362577afb82a73c53180cf872b0c52f9dabb5d6af40dafde29f1ae86e689f0cd03f1138013ac656bec83fe9749dae1b334765b80a0aca9210d1c6e3730e8bbefbcd622bb6efb80fcf3122c653c6b50befa5b0b820c96f58cc9a684fbf4bcacd72e517c4c44886de64dd6f007bd0b052453d1bb311fe53fbe5e7ce1e870a0036728fa5e5bbd6fc8be90ba6a447cd0637127c5738582eda3f1d41d1d5162e267a8ee277ddfb6c8d3e5688b1a9ade73a5b18531dc4fcb2e3ee059849a1babf2eda02deec16deba9d5d1e506a547ae3d201441b8fb64a7b5258ccf8bef4273e7086ae55ad99aec6177ae6a574b1a107e55ae11c6d7d1bb824f2040c63e6cfb0c725cd8bc8aec78b6ef6e7bd6802ca261775a83a42731566cb2e0bf27f1fe9dbe56230270b68a09ed7640d3c48707df7aaa962197e247bdd99f6fac0324ee9c69f860046035fa56a68f57ae07ae90b2e08f50ba2e7b65431551aefefbfbc661317a07559b8fe8a0916200431eb8999d7c0b7f23206d9972daa1a1ae089a3e1024319087c8c9bea45e3cea2f08454086a5551efcf1d6d78ddb6cd8f10027c65553fe8fabe5cfa03cb3d67d543dac1bc9b060290b1cd093131c69b348e6b2b5854c428891a42a44e24bccea0923de2fb9232fd7f3061555a420fad0d865e28082f39704a3388f54be23a0ff377a0231eb82f4a7733e2c0d193d5e52b85ab5f0a84d80f036b2483b0ed7625ebf32eef0cd0253e3d2f707571efdc8fd4d01c2d09c9cb08466ebf39a14d36b8b8182c482f2046c78f3c7a4f3884313af397cf6a48aebf9bad3162088d964d7960cfd83b8d9f382613db89909bd19378bd0d9df58dad7b58b121725f51f974affe9ffde155e611e958970abf732f881ae30ec03a8816c218d9640ceedc2a840e5426a85ef496811a47271c26fda785f15f73eee8821ad1b20ecba7900915d454ba8a92de228a2e492dbd37a8289018582f6355e036ff609cf0eb7901e3fa5c08a2f802d9f0602d23145dd1b1552447082e122133f5636c905148b974a1a8df01678878e0de1aa5ee52c01db1e8e1fb0ba16c4e84edf151cd82b19d74c2fbc0e7cbed9d693442e354ceeb222414ff021314fa3b77e3e47bb7627d4f5bd91882c68f119eb9c693898f60c5874792674f8f2dbf2d79c0a1cc0c760c20aa553b638c8bf6801402fbd8491e6385494b593759745f524381a0744caac0116ac242eebb2369a24851c7bf6d32e84cc7eca208968e6640402f0c1f72bcc3dca55b300a710328b97a79d63d7225d59a5f77b15da333f1c230ccdefcd0b578d4547f16113e3c0cb21a7286e2ad07c7ad72146769711f3a3fc7f4b79c1ea685054533164e0bb7446465415d2c671f40723d6b48d2d616469e5a3c58fdc549e49f4be81aa02ba56dcc4c3dd555d89914955b7bd517acb7b5773d9f6952895b64fe05d683ec475d489ae9a19eee1073372ea4a7e2759fe7675d706948eb73b17be77b747d9ed5538811e207ee36fc81abf3fb310cad6c0a66799e3719d978927ebabaa93deaa7306f52744e8dc8010010a04325aafae936ea81ffa82a09e8a51eafc9586a905daf6792ba6fc30ddec7acd6d6f354a622c44a267e8a431285addd1bc5fea1c5a1eeef752cdb6bb5796e013b5602d537f99a7ff739c525175783da9b7304f423afe364f5125ab64003eddd8c73f1b42111f2db44e0a937bc3cdc906c36a6b70bb8051bad5c4b9ba631eba23e66844c531aff63547969826420869cb4af079f1a8627614a8482b82ff528cc2486f92ae1f52a67ada364d076094d7c22bec1680e6a67bacc3a68e12be0bc5a02c3665729265e59f14f5d6ea78fa385699d10a6648ea57d5536d5070efe717de7ca0cd9cb3f82b4501ffbe8b620bfef59f4e34466bf5ad553ccb5bd33b03e18e102e7a788ff0ffc5b95a1ae6d8f925818c7998824ca4a0cb1e67d19c96f8719ab8cf9bac13c9976667fad6bc713684a9ddb51a4b409aa9cadae909f365a096a05ee2cd1072fbf32ce6da48bb223826f5def0facb3d8405927de276f7d96fbfb3c41b1793b881b6755c09284deb7daf579f844d35d1d140e0a209657392a27ebe5429614aa32cb9446a5a86571f5fc68dd021408eee648cf1d6e8cd09917277a2658a2bcda65e60628176578354749eab5ed93fae8f69d9ca8eac7a3c6e3865761d9f0d6b1f089e8e29d2de8e8995ffd50680efe8c9ebddfbba94bad1a6949744f9b5b9c837b26e13c890fca3ae840c63fc93dd601ce6e495a571a1c94ef8d910591effed0f0c38fbd4a0b7928a628b52ef3e343e5c4056b205b07ee9581934ad32eac899984a55f0078d077540cb2656db5b0aa3e7087dfcd8e67b8701fcc3aac9a2019f4b3336391ce9eb8b3f0dade3934716ef763085a12fda8d87c9d3cc383f5b16da598d3bc1e43c731eb75eb795715cf34ed44246d4eb3325bbf2601d34af0e6c8db87d27f4facaa015dbe58641578d902268d1ce314a1584f7b0fc0080cb2f22c1b681d545494016380266d3a368472d03a100815302b05c60ff26ddd9c4a519892c3a16230d4d17fab74b556483968cb592804dfd50db2ac5c28d6ae43d7c1bca23e87408e988cf99c9659bc2c72249a236be8cc90576bd66a22ee920677c9b82fc039cd05c147ec3fb95bc60d66f017d93dd4c23d944460c917d53e15af57c98c8b93b1b1e58a482ee5825a99031afebb95158ea7cef78c47b9380c9b6049b464dc05a472d66fde9357e14f1f9a9eccf6e536f3257ddd60bec63c905df3645c849cc5f828cb3b882ae40e75c9ea121afc504c03c15c9eb67a6e23e6672ecf01d0fc97f31ac4b3aefa4f5a58f0b68aee9266bd0ecdcfa77886fa61f8c9be807942413b8165f14e3f361a79e1ae1d13cdf4d9da3a43c8eb27abf513235834d45c50ce33827dd2864ba0cb19ccc283b840f2f04ac0feed646edd4fdb8bb2cda58ac382a9388b53a5fac198fddbc391d8d642204413645bd942d860b4a22c42e23458287417209387dc81e0b1566ee89e269f5e2c7351d89426b1e3f11a6658938aaf21543be84627efeeb59c263a1848d6863b7fc3d45530003962f46d49755dd047a1dac36ea5f3f4df66ded3bc61cd68464502b0b4c3a4c45e7c77d3daf61478ba77a4e4215e674114bdddd9668b7b32d28d382adecf8c2d2e8ddd845ecd87d4310b94f348efa325e4aaaccf95ece64a6949c4586a5e0fadb6cb70fb73e66d645815b2633bc53d72fc8c38d171b3a77c5bec52d3aa03dc909becbc81c0565c836f5cec8b6637cb34c681c1ed212c53e3602df8e83c99702b31304f440bb7def3dad3c01f1dfec6db8748a6e09a9d1e11840bc402413c66d76be521eac324d8b22cd97eda16bbdd9765a2f27204ed63e641d0bb588567a75b006bfff4c5dd0b344ef17f584f7df80dac9c68cce44d526db1c315cd5376e639a29aef24a1550f3a8ee30cbb6de9da7b1094a07bade8e425eb83cf866fced0216d189aec8fce309e5f0b876a9ee69c7fa081b2228fa94f71fba39dd603abcf665a7fa9dfa4948c1f72e5367b3c0fae856b3efd10c23e1b1a98f99a1aaa0dbeeb7b2e83b42ef7dcf3ffcc801c00418f737ce7a0d14507e4ea7fc17a4a47b87d4e5fb10b9618b121695a1bda327213d3e6276a44e06b2d8c958f54ce9eba3a82c514ed0065f2ae1e989e8f35e17123b244ffb76a528ad1c5cea33c3e21b7d953494d5deb940be35aada252c83fd8bab94b640b7cec005aae6e4e6b32477ab1b8b881a56b6b2f26c6f0b7083d1ebd3e3054286993eabd54c8ab50518ce584190c0126b929e38c2f8db128ac435fdefd2083a277096a224ca262fcb4e84b60c2780403fd2fb4fc7609ae7120ca644af2998682c87bd456e825f2d34f4e5c64b70709a8a1a745096a88484146673eab2581f8d4d893e38ddb224cf57951ba8bc76feecf91ebaf53f763964cb16624dc0de7c885b77cd1704f57f4aa4c29687f6b193ba8815a3f035756135b96e137f600ea678b763572dfda280f34498bb647776e16c99d4223ec0c2ac0f65401c5a77da9f1a1a9099dd683f8f0acd3ac11715aeb83b11a417a2af0c3beade1e3871bc3fa2051b0ec018ec4ed274f74a9994431241b07d9cedd92661f93f07b6a61ab76bbffd94bae818d9a2bdaf420cf7d5a7c0165a89f5ae6f4ad130e1e0a066776738063093fd364ba2130d5491b6d365c5847922fdb6045b0aeae31625b53c8b1254438aac917b441a6ce70612433671cc035ef03a6ff81b9980d12479bc1acb934e57ba1764dbee1fba0fe1b16d71aa2ab77a38c1f83543422b483b72782295a4e2476a436ac45972ac9a092698fd7327a9b3184bc407304310d21e45da26c3cea1458753c445745566a6ed2af92437aeaca6c8327114cad04553fe4c3fa46d8cdbeba2fc803f6d3ce58f32846fa13dc6302229f3d50150d68cd673c69f10fc622f04d4f10494cf407925a4f8b53e70450e6fdb8074df8c6dfadce107cea277d1fa692f756dbae8106ddac72104ed4a40b1c9b38504d264d1409a1333000f292a50de8edea960738e3f3f581a37c00c9e3ffb52998640ed6b8b065526baf71f0711cff3528b0574c13c3fa71e441024e3987ea42777466a68d83e3544f4957e5b10888f43f05ecd2b5576b24ffd1762763bd398fcb4c6c2f7a624531f12080a0c261e0bedf3a7e9407e9ecc5963ffcab811e6d8db676938f1949ca0350d4e78d70f33f28ce31282bb0d6d25f204f32cb9a3dbb2b278e35993249ec3ab4c8da233cf3b737804786041aa4865f0c9c000ea20e8c2ad837384d05401f0141d6ea88eeb8629745a7f4842207ff7bd7bee391ce23b18d1ed35c89487539c6b505e7fbde627bafb8293c6105e7ed8163c560e363bb1b17f269261e2794f6cb2f45dfe60117cd0c7d66cd42944b9d7f7d62716b32d52639d3fdefeddad889055b8534fc0911109e78d2f209693d6097acbd527684eea1af15f2b7a592b9a6edf1738713d1c25acc24414299e31aed8449ebad5352381eef46e7d9ed76471be2c49a8d4cdf4dd8febd64bdedb4320d7941f2ae31c36c39de1459d3ccc443fcb1a0b0f43d30c22a62ac761cab03f7878c9dbced057fc1ca923da01ccec85c6a64ba1fb56a31c752aec8cdaf7eb0b17f120ad2f122b8ef627e8d807c1c54b3a77b48a15868a9ebd91bd916806daf3f26f1fccecdebdb14cac0760844a0f1ff71157e9a7285fbf5ba9610e040f05c18b6dfc4d9aebe453a0fc34dc3d3dcf41701c308a2307a695b94abc6b87288b10e7b785c1ee382978bb495d7af4f98e913bcd4301a69ddc72b514aca8389aa25be8f3d7d1d0eb82f157956ff34b296b578d2050934726143951d0144e16fce3b26eee24498226de42480701fc4d34dec6db4cbd1af5bc283d4554cb7d56b3157aa191fa15420bf8b63658026c59ab4e65ace1e172bfbe2d6bd7205089ec1fa45ae4691ef029a9482224e615ff472529ff5936bc9d987de89b9509cec8ae84b785202156e82a3197af9b7af688d114e57cd70c352b58f7f09c65faf9c87eed26b6aa8cc4bfc1290c148e0072cc136302926c9455b7961efa6f30c9c7486931c2790e7e580501a401cfaa70ac7eb5d4fe98ffac900251b93d55b6fd9c14672eda5dd651f1bd247afaad571473a1580860b580edda84edc82c3da03eb400226bc180a9f9d1bca8f3fb1dd6051300d3240a292f4ef0108620367405aba45322ff0355f366dd6fe0bdfbf4116b95dd9923fc7e02e548c6d6cb6f4eb95407ee494a778d9532dd75074534d83458aef5e906cb8ab7ee63cf0498ce4f066fa30be0f2e5f3f511372b5d6e8f4b13aadeb2f43faa1ce69eea4423e16383fa9e18adaa3b65435cbee387d6e7d1b0fd9cd1b0e67c49cb76b0fc28c5362994d1db08b54e604049221fc68a33a41f4a043b4d9916b8062876661cc063ca27e3caa65bcea2595fd4f557a02b5b33af6c326f855b031edaae6e24f2c04de0ca23ea8139123a59b8a3d70e004e21e7431b9e199c8ca84f335a8eb06019d1972396c74b2d4b62a984ce8e7cbcb73129db2f458e4f0dd033d579be2006126cb57e610c1151a01465e21d448d747169bb8b5740bbfa35160b9bcf09624a52d59ccb8ecaeb6f2a79285a344329ac3643aaa0cacf2b44a3a645aac100e4c53a1ab16b3cdca9dbf38332d72480c8124ddaa5a15a5ea5679444f627cc76456258dcc03c493a3dfc1271d6653bcbabf1f5779011548cbe8dbbc6d3315bf2a6c03d653bde37d9c08bc831055ae469399574e51e86fc1bc3cee4fa4a130839baca5030c75970f6c94a0b59786d2de7017db773e669b2f6e2a1625e2f8e07712ac38c7d343c91e2e45bbb0f78b96186ec724ca1c42ded0536cf06bcb920008caf37e6d91ea45b12857096a11ccfb6b2baea36f577485d7970d02eef1f728f03479d01707c14e26e3ffbccf062ce02bb7be9c7dde6d503ebd10eaf0261ea49429793f2ec0657ae4f583559c65d1e868ffbdfc6e81627965bfdf7f7239dc7224eaccf6f82780c484aed897d76a38a4bb84982c0b671eada508caa8d92fc6856a8d2b740850f54ad6bff55e5c9370f9852be4b8e8555aa0451818660185e945044bea2af6062b568649d54c4bba45fd85d32788c427ceabe1f67ad81441057a1b67fe5f1c4c33faa197e66b64bba5d13dd737c42413bf63abfceaecdafd0879f2df6485202de1cd5b02f381b3da6bf2cdea8b728a4d71d0154f472c8ab7173fd4d9f505545ec58508e0aab815900e9e917a9208f19fd7212ac9dd2245e342ece79022b24a45cb7ddec3e5c531808ff8c1e741eeb6cb64939ee5de76ac82768731d1bbee48562a113e96b66eea66215fc357c3ba6061b9ff835d5b08d606809703756cb9ad8bc22f607d4869667355692d3ac8d66943bdcfeb3ac5543176907d2bd2978140f71e5be562f91621461ca7b3744bbb9d0362773ce01f83210e689d25fd0b1af3912365a6872a51d9b431d828b4a17204bbd8dc2e400906bfae64d79840ac0d656e77acbea1adeb6331f65507e0c9091acfdacf8c6d69dcffffa4e0f4ff27d55c41af59edf89385b1a1775d1a11d98291a5c998dfe4d8f569790694d9aa52d4ae6d528a4677f5e7f962359e73340f363a70f78d747431a8d92108197756c5a69e8d657bfbaf756dec0a5676412d2c0d21c1955416f31bcabbc48b2966a3b9e7743847bff04005f2cd3aae652d661b3583486b6f2f1eef9ccfd2ecd9152727f261a62911dab71c971794572d59c32733695e94631e0deefbba78266fa4f27c83ad04311f58a861a0f442d5965f6cc6cbf377c40818b44caff66cb887ea90474483f5508c9714176735159c2583c2b14dcda343ad7240373333c0abff04b548a11a0c9227468b8709364ede00ad509fb30e72aee8f2e0cbff1c0248490f10a0c5d23c5f65a365499d42c67dcb1fbfb8414337febc4d3a24b718bf51edb444d2b4dd629a9bf9cd4790ac3a31efd9ed38f5bd1d77add9a664c81f92ada359b9634c23bbc610947c5c2eb645bae6194e55a822fd75daf827bb4bd4424e67257b18b5c8da22221d248e14b1b2985d071e0cdf1cb613b42113fb53072c9b6c1a680b235632eca16a7065774fdf1084f5da33491ee7e7c15e1da4fa4358d2c74a6854b4f69ca0fb148af1a85312c8baf141acfbe7960aa0a4d5e46ee52e1a07bb24c30c9208f4bcf9b76b4c259ede6dff0cd5d99efe91118ef0fd12f5ba616277a7f6fad5e1491238a1f655a1ad64ba1e034b38798c62995871359326a8dc698922ca2679cb4c8fdea2101c155ccf5408d76890ae8fd89f8f8c3111f055dc7a9834a0d2704d8fcaff324401dd2467c3fcb0e41ba5d3957209fc28bfefe493dca85f558877ec8e5957a464f64da10eaf91c66e42f1660d5d545e4ce50c3f545553f35c1ed4133b0cdda420739cf5772a4b03e8a56d473e0ab702486693d62addcd10560daa5326c17e57c117b90fd1dd5554d28c6ea6fce76a61c864d2b8bdc8da4fef7a80901e9423349ce165cfdbb0eb658e2f4855a2e598c404d009dcd2cbc3d570ecc775ed1e6d6081b3b57fed8a3ba11a47d21fe897533118e9a371cc716a7cb6e05adaa4f64b7b871c2433cc1fac1921350f93ad1d5e13a1fc6b45321e11487452f3d592e70b17ae506ad0d58b5a516a6545f6633f61d053d5f664a59702bae16f6ec0483104b73eff55f58d01de6e4d4a21ade74d31e84a2a9520f62e5a1a9961cfe7c5a23c9c4b1c3855508d6876ee7cd4dc6a13e2a3410dd908c228e21e6b1e902781b00dd78d2d07c34dfe942f37a23c441a43f4567d0dd83c129e43092296c3ae3dcf5dd3e72902f3587db2704b56565a22463a5d1a8cba9ff78358dfb251eeee690b717d47e85437c081166b66a98150595114c761192c70761365636d8356a68126742cf5dbb08203cc853c536131dcd50f90a1d6ddcf367f4f876bcc7ea6819e7c26d318e351da7a4a97840327f66991ae196067a69542fe879264af31e66fd30c789bb10312395efd52c3884af22fcff433c001f3710f9d1f3a73b1b5f7e5a565684881cd45525c814b1cd2693a367bd116f451b17022373ec2ee8249a3e4f74b6922fc492c931b0a1eab2b208e46942ecad3ec586ad459069eca8a770d9b3e75702bb11ddf9686983f656a73caf57ed302e2c8c1f467bec7250c6133c7b55dc84e87123e5ff3ada7e7e542630bd7ce25785446890b80b9cab724e57a1ee7bdff0453cd2fb74dfdb1c7a136837a2026d4a0799aaf743d0190a30d09d3b739836ac8d9fab21eb01aee71fda3393ba21e79bffc524f742cfd03d12ee3ccbd123019f280a98f4aff11df1d2643ae56aa41af46655590966940ef8d063fba49673fbd271d27063d93ee736a109a54731de4d060086b72f377f094d9b2c4f321dc0bfd4a312d970b505eb62ae7851097694ebeda4a8b2a512423cbc82bffb4947dff07889976c7d5077d15deeeb68cbaa444745a041b5538b75e31892e2d8f0528dfd6786279eeec2f120a000bd02f7266c4e4e9a9c35d5dc92617698c5038c2ff45e57917135f583a0ae05c8ca3d54bab61ad935a0b079de9d76cfbc0cfd16c4348f0f902b4dd3adb95b8e94e1dbfc760bf5dee80aa086e46083e4649788d0ecace78e0c92fac371b14d5c44a518312c797c612fa0a163e1f689193898489feb374cf8e04c802189494b926d8f8880eaf8d3aa8957925d5b2d220d1c63f83794c1a02c9b0a8e1b3c002636875e2b883b6a20618b2a7eb4ae69b6a96e1e7689e2056f45ea72c23b43ba23b08518a5e84fc9aac6f8800b745c1ebd93faf98266ed64e4c9b2d56a6d8321cf57dc4cecaee3215d34676b96fcb8216255b71a35f2ee87a1d3b27bb6c4fbc2fb72828a9395284568bbd888c6b770f84789014023037ac65d9e33179284bbe1b89d90dc2522f804cfaf6fe7992633e820a72de0e99a9da24e0b26add45c7efcba139c17a74b547385c556732d36ed6a80051f3442ad924faeb74c820d11b3c0dd0ef0911ac5e4d76344f7ff89f35b31027f7437418ea0df9ac70c28a2e09a6ea607755e44ceae4e5e9f07a2e49d74c09484be5f14a0dc5d3f485ae560511cabe7d6712102394db84dfe172eaa03e2b01508114e68a1046d8cefece0e4fc1042f031053ec0ee428f9241848232f8cbd95410c764bb4d8046da9039d870a0d608181f4a5f517eaa53563c0871bc5f2caefa47309ef0adb9819405ae80da3af597eccf6147b08f690efb0f5602089ee9d867b3d9787056fe04759e7293f12cbe0c50b035e2103386c7b6dc358f0e59d52af1131ce0527e570fa180c73cce10511a7129e32565da174cfcf36fdcb3bec59640322a850a2812ed3328c90458dff7592e826fb6e9aac777105bbec2076b81b5a1eed4389b3e434bb228aaaf59c0ae32fe6c918e3d8dc74eb175b5abe4bceb569e3ca74aefec062aaec09388ce2708b1fe5bd40b7301fbf32435202636b0aea6501b0951a163380e63ebd00a95bb6d4f2200492b30cd3dfe19f0914b6750784635d9c65969ea2bcf667b2bc01510aad47eb240e339052fe8f33c1f862edb1732346e089da2013319ce43c5c569605dcc355b5e3b23dec190dd6392c05184fd8479c33215299db1536f8537f0aa2ff64f7480ea6444a6ac1fec72467b6601ff2d0abed08b417aa3ca4feb01fa0a42234a4793077f225c55fe39bfde63264358cb32b7c0f53ababa8a689febcd4d12c66ea9f89207a2ab7408d069f6a8fd665b35c00139ec7bc1159afa0e5b2feaf2b111786909937ce2f64825fa2da0109e700aedea1d602acdb1a7d0fcb252a3f9a413b88de808ed150b65068b06bec42dcbd7286411f066abe70c7ebd89a7d6045d7c72bc6bdcf22f3de7d8e22b9e8c13679accd5e8ecd5dc2fd084c4cf1150d6574ba9b838d879e372e2ef365aa5bdf09965cc711b804a9c268a8e27c7a8f2ed49999e8505dcb5332b42cd70b540f5360e25382fcf61989848b7f54d74b508a35a7716b129637da075fc39ae9dbba28632cba703ae5163970ce2c6938487fd9479532e334d5e63abec6b38086b633fb8493ae842c7c589a519092e13095c0d380bfc26bb94bb85c0b8f81086c3d918697b8618761e94f61bb96971cb4c681ade38f6c40e3d651c44fecca1c6260fddf21c9f0c05169165ad298cb2f942f1c44602abfe36db61bc05c09ca3de59f268d6e60d0607186f56bb9691f880e85adde20c386a8d5d2169b30d5fbef316fcbeac64d8c4f48c3fcdfc3e6000bfc69ad746282e1980dba7cb184c2e1294fd21d79fcea6681c24f22ca194ec339383926fbe4e7b5c0fd7f6a86cc68606397fa903f30eef62de876ec9bd113ce05fe2321def9258ca7f488b96a821b54eb9abc0b205202762dca30819b13bb1d6901cce1cac6f0c15e07ade9cab1ca2d5139736d11b4cb472ca4beaf05c89325f1c85188cb3fafd3c8092db0910a718ea685780b747bd6dfada3885fd5ae160e4fa6d8c9d492f629d0d7922383bbdd1f2de3b6fbc224126c06d9bb71ae5b307ded4902e45088bc4a0f50344fe2c1d331360f8dc892b56634d11eaa1e8481c63b1943f3a4738ccf4a8e958b38e7d5af349f91adc98c61a1dbc185fb053409be2c35add637b27713d55b9c93bc97ea10107500153e5410f8197f6904880a081baff64d17091eccdef5022a838b0b57410b72a52a350c19b2398e24401c1bd162600c5bebe0d14e96c88f08118f2ecbc804f3e238b442494b9b8f551bf3b80590cfa20abaf5a0c82b4391c9df6c79847d168fcbdf3fb195d5a0e1a0b0d73b72a68842c48b99ad7e00f54edd3827007eff16774d74b583b99aa5809949d003883edb5148057d15610f7f1a5ca910ac35fb683d58be5b534f4424f94e2662a1a5b68a86f2a94488ef1ec00fe815f9f084eca8824de2f87250b33b4bca4b39e937f2f35394e079499305de998340669cfcc95ee267fabc0a8b7b869fe784ad50a3cf616adb3fe739abfc89cbc4da9c0a3bbb1335d144b05bb30d491dd729c1e5c53f63b1d93f2d7264a820f36e42222f3ad170d8f29f75dbad6976c57ac8bd265228e219ac846a569ef43a6f8a4b0957bca66dad99fc3fc4cee8935a11dfb8df3e8b02f95e5f821191e78127201db699d80c0363ec603e16dc41257ff1f6e0543dcfd09728e54f458c4015ded90116bb583a5892e9998d8dc271a75fe0b7d0fcaed5fcbc71a1f94db33cf9db665ff19d0372d1bde248ac3012aaace6777f6c36249ec5e79447582ff77b9a24d74d623f3bc70edb7afe8ff402be59a5213800872d974c1cb2ecdccb1010d225ed57c71e437f02403f931816ad6a7ed29dddbc763102a89eee65a42ab939a126230dcd3a54fdaf762484628423e82ff60911a227e99e53985e66157319e94079e3dcac14d34ec7238125fccd74cd8ac03d21ec2944e42323760146d713c0c15013da1d59adba3be6f6fe7d8433afdc6a07de7bec14c374c25efd119b5a1ff8ecf4bc1017c440c2878aaafabea7bf896dd0e1ddf63650071074db0c1ec5085d9021d271798454f07f2543cf6289dd277d5a8ea453e2995b3f69ffb94d8a52fb59a9e6c3870d185d6f1ba600b7f0cc973b3858cb95207fdcade3df8242565684503ad9562fdb7c50274629529c4a501a0d9f3c75278095e574356101bac0d897513a50d6762c21d3889892cc1b4713e45ff75d644db61aa932e66e6cb7079f9ec51e28f858ed3fd0567b52f291137e9ae12fbc617961cfbb3dda5087b6ac242f0533f3560c716918d368478e10c9c1b1b2cfbc1c88d795e42813422cc21c4528388715555ed444d05970cb08841c664ede94f85d58686d29f6f9cd4d09cb23004e2ed3f1593f4758883949ad20281e426b1afc2c0e9ad3c0ff7f226274935ab47ccea4c2fb83656ca329ea8766a54d3b2023815b39a3fa68f503390ee9afb32bff5c744be91c0c6b709435f0c263a4a1e1d1737bc236a87d73dbbc75ed8e1562cb5667c0e2b05d32393ed88dbff7981ee3d901369fffd6dd6216deb72877aa31a211070ecd400874b8b3a27dbf5b2f33d88ad6a09ad9f8574195f485d53ad4fcd64b946b05572084a605004ba7617131254239d6f5f1971ba732a832db9e4249e4175e1ca99ebe4de7aef65afccf2791939047723c72e0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db162be0ec4bba91d1ef7f34f35240f3895211be093d6605db20c6bbef66438244f3f4ad8f9301c54ab1b8ed573d5d916c0f3945f813245b723dc37c858d6af5a9f9c90f5aecd6664cf95eac8ff1433b1d453c0ff7f226274935ab47ccea4c2fb83656ca329ea8766a54d3b2023815b39a3fa68f503390ee9afb32bff5c744be91c0c6b709435f0c263a4a1e1d1737bc236a87d73dbbc75ed8e1562cb5667c0e2b05d32393ed88dbff7981ee3d901369fffd6dd6216deb72877aa31a211070ecd400874b8b3a27dbf5b2f33d88ad6a09ad9f8574195f485d53ad4fcd64b946b05572084a605004ba7617131254239d6f5f1971ba732a832db9e4249e4175e1ca99ebe4de7aef65afccf2791939047723c72e0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16f4a3218d74907716c22e45c5a730b6d1a31a72d185fb52dfda89899afbb0d3f2a6ac44faa156f81c19a8fd1ed59cea195f4828826bba44ded22f5c6fd0b43fbdafc0328be3cf34ec51dc9320c3e5eed71a384ebb3801762fd9391856d0e94eca23f2c8cd3cc11c22e52a83a145df21ad1176f0429fb397ccd1dec2b7bb7a327aad6ab513fefb69f14e66380b70c5be9217d84b53ef047481e27023f1af25d2abc8aa18282d36a0a1b12bd972361d9be14d9d962a3f42f97ed2683af5a2ab0de4a46984317b6d30d5ea0d54bbb95e012e9153843c59d080c7b55bd1efc0e0c7fe144a72b619c657a7b9bc3b4dd7c88fdce4e27dc6470d69815ad3c77ba3e3c9c25d63d8c4feeb5f99a71d1af188372cfe2e383ccfbfc8f5ab51d83298a06b9fb78c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db167aeeb6b6090e3f9cccf682f3bd2642e5bad135bb03617bd6f1f7980f84ee6a17005ae22882f77808a8aa371126600f4ec4f407768a045cf4e82ebfe963c828fb191f8c76ae3f3cb35865e1817a278daf9773e283003ba4ceb036701abd445c5947860ec24556f6b561f472a3e4371ce0462f40b828382c7838fa0488b4069bfd0773cd300b70519dca844a98fe5f36b03bf0df874c45f7e97a5893fbf1a71f4ebaca1a024d0a56d029a20d4b5b31cbe8eb0c4c07f90cf6537e142d9bfc7b4da1eec8f9898f9493260bb91e20c145291b95a3ebff99f56b382d7ee6dfcea81f6594ad5d40073397b90942a98c68d2d1709dbcf11520b3876dd2bf936768a04e5cd52c246e5567e6428588414d0839cacfbcbce1a7488bf8bc6a1c9b4ddc020567418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e90551620834c4a6999e77688f69da059c6a4e249ca34c1fb361d60d37c4b974dbe6f57005ae22882f77808a8aa371126600f4ec4f407768a045cf4e82ebfe963c828fb191f8c76ae3f3cb35865e1817a278daf9773e283003ba4ceb036701abd445c5947860ec24556f6b561f472a3e4371ce0462f40b828382c7838fa0488b4069bfd0773cd300b70519dca844a98fe5f36b03bf0df874c45f7e97a5893fbf1a71f4ebaca1a024d0a56d029a20d4b5b31cbe8eb0c4c07f90cf6537e142d9bfc7b4da1eec8f9898f9493260bb91e20c145291b95a3ebff99f56b382d7ee6dfcea81f6594ad5d40073397b90942a98c68d2d1709dbcf11520b3876dd2bf936768a04e5cd52c246e5567e6428588414d0839cacfbcbce1a7488bf8bc6a1c9b4ddc020567418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516798f137798f46bbb03369e731d352879a7c13ccdff9357fa8d1f28c1df6c621c6c1a5eda4bd9b4cb017dda66f77e557dec1165088655c86dce4662e9f4a7f1796998d8625caa7d692ce6e31d4e36e944d76a983730329f7ecece28decef57999be4d818699fa52ca056f44a06b367610f48f7271a1d1d559dff37332af97203e9b16a581b80fb0797f2e37620533da438617ffcc602df6f7131e86bc16c3a5f9b7f48d3751d17ba0a5b8fe02f493b1fba38f4f6078371f0c2396c6db66456a1a40bdf1dea295ea92242828644e07076295a3ebff99f56b382d7ee6dfcea81f6594ad5d40073397b90942a98c68d2d1709dbcf11520b3876dd2bf936768a04e5cd52c246e5567e6428588414d0839cacfbcbce1a7488bf8bc6a1c9b4ddc020567418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055166fe90b8f1580489b04f5bcd3fbbaf7b1f267b1ce72372e92a2823bbacfb1120687a8376f7c96f0d07ba868bc66b1c365d07c90d2a21f99e1c85d198a24496e66966a5002c2c67df8f5c9a7e52a2f41cccf6a74f32110ac85682def7dce8790c9cc27b2b27a1c0c0742f72e027b13b475a29a7391e5e320f9157103b6a2f9d44ff55a3591f45bad0de92a3a3bf77873df323fb7debff4f27f437f367f604bf69bb458bc41959eb5b793eb48afc7a5c90d42c0cc9318652d7b50c8d626efcbebdcf33f89f3c3e6e71205ccd90ead26dd6ada8fa6def8457c8d21d80a9d03fe81d7bd79bc35d22bf88f336c1f4d1edd6ec5912f1ad088ad879f1906ce0acf0ef58e49dd49695f3812865b853e9d3ed48993d66963ab2f621bafeadb3aefbdcba741ed4916997c532704e74c8bf77ae7808293741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e9055165ca6e057500996555370652da25081c9f267b1ce72372e92a2823bbacfb1120687a8376f7c96f0d07ba868bc66b1c365d07c90d2a21f99e1c85d198a24496e66966a5002c2c67df8f5c9a7e52a2f41cccf6a74f32110ac85682def7dce8790c9cc27b2b27a1c0c0742f72e027b13b475a29a7391e5e320f9157103b6a2f9d44ff55a3591f45bad0de92a3a3bf77873df323fb7debff4f27f437f367f604bf69bb458bc41959eb5b793eb48afc7a5c90d42c0cc9318652d7b50c8d626efcbebdcf33f89f3c3e6e71205ccd90ead26dd6ada8fa6def8457c8d21d80a9d03fe81d7bd79bc35d22bf88f336c1f4d1edd6ec5912f1ad088ad879f1906ce0acf0ef58e49dd49695f3812865b853e9d3ed48993d66963ab2f621bafeadb3aefbdcba741ed4916997c532704e74c8bf77ae7808293741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516bd101d7d9e732abbff121ee34b6952001be614acbd74fb83de7c497b7ca16ef44b9e97544f0e533f514d1c97e383992395b8b4acda4da4cc704ead258d683d778c069f3a81918a6f056260b50fd83f1be0c0d8e0036762ec16edfa89cc64764ec656fcb258b701eb37bbed711419d848a0aa79c35c799b76f8f6a38e3a0460237768af11a77e3bed304b2897cb7d3384cec2e3dabbddde31930505aeee1ddbee4a0faf17713d79133060ce919966f668e8a7d30ffeacf48e34bd01aae2d193da81e63621164a1d013e2956d018c89fe7378f0b3a73798887979efc5b0ab51d24f1444fcc0e76cb7d4137dc4cb2048a8243fff675422b0c39f33b6a9dd6110c190cb30c15c7213301e6c9b41e9abf3e2bbcbce1a7488bf8bc6a1c9b4ddc020567418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055160f9a28c9f44c51018b7675e77d02b65ae0cf970f8e2e001aff923f6326d2fc86eb8e9634b47b00b0e8090c2a3798a65aaf5f51ea0de0db2d2c5ef8b86e223b58222d75438eabe030bcce49198bb00299ecd41471c07d0bfccdd4195b10a198396f8ace898668aadde2cc4f6da7be869a05d2af7aa3a178f49a51bddaef20890f8bb9bf5cb9518ce40bcbc46edfcfaf77a92d62606921779d8144d5e0500dccd450c1af4b0ebe2a0377ed00e62b396b720530adc95bda8c29e471c7c069f39a231afb2a0168965af3200845a538f340426976880c090c5b1c94ed4bcc77eca975e97893b490c3693d6d31d20ea7b5dfe3f739dff233960740d7bc553b013e30e50cb30c15c7213301e6c9b41e9abf3e2bbcbce1a7488bf8bc6a1c9b4ddc020567418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516bedf2b3449a86a683c20e0303029a4bca6c65c6c6d34c6def6dd2edf6e99d072710f3973526f27c5be00b6601f3610afd73c4d3087d9a3833cd7a165080261a0222d75438eabe030bcce49198bb00299ecd41471c07d0bfccdd4195b10a198396f8ace898668aadde2cc4f6da7be869a05d2af7aa3a178f49a51bddaef20890f8bb9bf5cb9518ce40bcbc46edfcfaf77a92d62606921779d8144d5e0500dccd450c1af4b0ebe2a0377ed00e62b396b720530adc95bda8c29e471c7c069f39a231afb2a0168965af3200845a538f340426976880c090c5b1c94ed4bcc77eca975e97893b490c3693d6d31d20ea7b5dfe3f739dff233960740d7bc553b013e30e50cb30c15c7213301e6c9b41e9abf3e2bbcbce1a7488bf8bc6a1c9b4ddc020567418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e90551648b733060189614f1c89fcbfa088f8e66ed2b903fe9d62b542b5943f1081d5b0fded0cbe90a900fed166c0741b30fa86c15d082d13e84202cb055f9fe4c8f74a9309d3ac46d58c26d6981b11b12973da2b4caefd44d0c3515d92bde01a1ae0c3b82ba11e852f821d38fe0385d018a1d747b9f5086e2c9a9dbd5cc4afc0664fcc07bda2190bad8997afcc7061b09e69f8d004b79bccca5a4f4df092dfaeab2984d5010c5dda0495bd626a459594dfd1636de771a07a0fa42959dac25811966d304a51c2d6767f766d7490da4ec6419f8487dc9b9cdc9cc2358ab862b094a77dd6f0f5a77acca0437ac86ce8ac8c02fb4743fff675422b0c39f33b6a9dd6110c190cb30c15c7213301e6c9b41e9abf3e2bbcbce1a7488bf8bc6a1c9b4ddc020567418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516e1844f466d5a87dec43722a173abdf0d1d080f670aa973b94a847803b7af1cf09f573024d6a0afdff21240259e06e0e59f509e3c45b7b8564f011c489bc974a2c6a0a7d2dbd4f4787bafe029d8f31b3f32f09737f611205b54ca96fdecb0f332bef65cfd570afe0c76297c3ef62705030cc37fca09027043ec8d9e3aee8dcfc075fc5432f1c2481473c0a31e4ff9c2451fef270efc48688b18c58718942d32d1ff9bdcb85c079d485e782246fb187d1faef1aabbdf27c978c2eb0d9593a64f978302b762f21f748419a11acf6810e547d8e8ccafcd9c1bfe99371ba22abc8dae13e6d4afd7e14de5caff06e71003e9d67ee007e4ea1d792c0eb620a94005d4c720e1b3f6932f00945113fe7cf7c50223456aa74f87d35120d414388aca0f8a8418e073733dc9d2e6f1b4c473add199deda630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1672ec2b4f436916b8d11dc361c926deef1d080f670aa973b94a847803b7af1cf09f573024d6a0afdff21240259e06e0e59f509e3c45b7b8564f011c489bc974a2c6a0a7d2dbd4f4787bafe029d8f31b3f32f09737f611205b54ca96fdecb0f332bef65cfd570afe0c76297c3ef62705030cc37fca09027043ec8d9e3aee8dcfc075fc5432f1c2481473c0a31e4ff9c2451fef270efc48688b18c58718942d32d1ff9bdcb85c079d485e782246fb187d1faef1aabbdf27c978c2eb0d9593a64f978302b762f21f748419a11acf6810e547d8e8ccafcd9c1bfe99371ba22abc8dae13e6d4afd7e14de5caff06e71003e9d67ee007e4ea1d792c0eb620a94005d4c720e1b3f6932f00945113fe7cf7c50223456aa74f87d35120d414388aca0f8a8418e073733dc9d2e6f1b4c473add199deda630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db161c11f518699ee89078e7dee08b337355939a1c09d76e9aae54d173125c4e8112a207fba78b6bc4637e211d6d387cc85d841dff7627e68b2140f1083f45ee94ab428d9520619f9e898aa043506ccbf7134e547bec2f3369d110098ec86e9989c267f7ffe02f1423ea87971fe933e68fc101a6b8eead77f7baf0b78f07e3d8d15ef5d16b2a7f8833254bbbf2607ec1ac7af172b66ee793a4f1743cdc2598a9c515bbe5979be0a13766f739a3811946d7ef3b7dcf3848a4968f1b2505dc9b4010f3dbcca1675108cf1efb74a5146da09adebbb09c0fb341bf2c79a35498ca24cfce8b7101c1670f3df3a76f03ee5b516987ff61db6d62241226a663420ecf1a49febc4a2c1d72b56fdf65afa74de0e060d6375098133659c8376ee93aa1330eb4d34a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db1696b57694201671411c7f6befe6fac9382c04c6b505c572e155768ad5c84870e975576fc0151ee3cb2a3808873977ce775ac10e5b4df359af77b28f4e6b85d5b3d6b29a20375b4fd87c5ee464a68c0a7389d0156250360a71335bc31d77c81a72ab8b6faef9af4217ee3aa330946256c768852645e25e577235a9bc6cb451db424f631533eee7774be9ef3720560d01fdded0b047a6f35f0c6fbbb03f084e6c28ad334316b2f6e6569ea29e7012208be1abd3ea37bc7420b24c2217b6cf618d1dc6a12e50447e2a82e50dc036b02d187b5452cea08f47598e940d3c9eeaa5d8e78618186755e933781101bdc1cb1c98712d88659514d9be6d52d9a862c3bb29ac5c7fb81d161c584c18a1a5cb27e4f8f5e44cf2f9f74c1ce563a759e864d3544a82cb55102e48c8595506b72cb557f41a19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db163dfa6be679347f461775aec193555ab52c04c6b505c572e155768ad5c84870e975576fc0151ee3cb2a3808873977ce775ac10e5b4df359af77b28f4e6b85d5b3d6b29a20375b4fd87c5ee464a68c0a7389d0156250360a71335bc31d77c81a72ab8b6faef9af4217ee3aa330946256c768852645e25e577235a9bc6cb451db424f631533eee7774be9ef3720560d01fdded0b047a6f35f0c6fbbb03f084e6c28ad334316b2f6e6569ea29e7012208be1abd3ea37bc7420b24c2217b6cf618d1dc6a12e50447e2a82e50dc036b02d187b5452cea08f47598e940d3c9eeaa5d8e78618186755e933781101bdc1cb1c98712d88659514d9be6d52d9a862c3bb29ac5c7fb81d161c584c18a1a5cb27e4f8f5e44cf2f9f74c1ce563a759e864d3544a82cb55102e48c8595506b72cb557f41a19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16b9dbcaa090c38216168adba81b8d65f18227ec4e76656c1de264a26f1197c07ce553ee3f576bc976792bbcd2f778ef9f908905dca48a53e8195e0a45d4e580ccea0a7d616619f32ed53a930489dab563bdb14271212b581265037f303acf87566e680fc3450552766875d7cf5698f4f6c488057738deac82347e5b718722895fb4722c9a8648e6d41ad9279c547fb1104397c9057db6ab9e2266efa3a42a18c0b55f74e61709a75140cfd64eeddcc6ac8927447486a5632ca25547b0f76d61f2d9c705f6b8c7acf64536502e8b122b6e28c6642d67161c7edaffce52e2fe291d084a605004ba7617131254239d6f5f1971ba732a832db9e4249e4175e1ca99ebe4de7aef65afccf2791939047723c72e0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1601459b6c88108c560215ba6347af75ed9bf31aa03b5008bc9a1ff03f6746889642f5bad54c7ea029faa68b9500ed2135d8f47be5fc8f55ec5729bd3623b7b1987081af90105ab1378219ecd3110d38b74f1cb76ea09a47008e1da4546cbb45bcd4809d5b32476fda6ecd7975d16681e4bd67d60cf2c64b82d05a939dcac6dfb1ee95fa4df153bdb1de52fb6d3f10d449bc9ba09d1e65384e011431fcc5f5a3e82c11f264d2fff6d477d51d95c2cdccdee80a18251c1a4cec0cf4c87d3ce34d501aca0595b48a84f41f178e1816fed02ffe8ee09fae924332ed477dae2f19acfb6c891854a873df8232dfa86168d7dfad4a5edc77a1f462b8503ab4263de32e9dc5a624ccef5adebe6bcc65c13faed725375098133659c8376ee93aa1330eb4d34a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db169805d9622eb9116dc5211ec68800e96344fa706b4263c77dc486045b46076dcb1e80317745aa3ddb3f9d1b19697e716fd8f47be5fc8f55ec5729bd3623b7b1987081af90105ab1378219ecd3110d38b74f1cb76ea09a47008e1da4546cbb45bcd4809d5b32476fda6ecd7975d16681e4bd67d60cf2c64b82d05a939dcac6dfb1ee95fa4df153bdb1de52fb6d3f10d449bc9ba09d1e65384e011431fcc5f5a3e82c11f264d2fff6d477d51d95c2cdccdee80a18251c1a4cec0cf4c87d3ce34d501aca0595b48a84f41f178e1816fed02ffe8ee09fae924332ed477dae2f19acfb6c891854a873df8232dfa86168d7dfad4a5edc77a1f462b8503ab4263de32e9dc5a624ccef5adebe6bcc65c13faed725375098133659c8376ee93aa1330eb4d34a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db161fb7f4a0af66bf7e5024c15c63aafa93b67308f50f1e45355af9d2c4393cbf47fe36377b71a52a2d80d695af6024a53433fe8c76204d83a39302523d7dc80e1cc10cf99ba9af62c8c0ff8caa5aec345a3c7039d8b4195204c5cd31f8d6411a51c214e75040f83269aa9bc9567a2d89af4bad1b194e7719638f8641643a898922893a52e8224acc9c9a1ae6fb899ac079158472b0c35894eeca96be67f59675bc7c7b1d4a6c95db7b40beec7b8ca2691f41f7c23cdb0d7a6ca308e150016a45ec4c7fbfd6c63d3ea441ffa060bb9ee5f02354f56ec80af8fcfce9d9393244a6d2e8d402838caa98989e52659366b7d6381f634807c1126f1de52ba28f75e16d7a22dc0818ac6d3b2f523c764abcc158cd76314f206cb489b8f9d7a8155740ee354a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db169a97f7ac8642e12d6e31dace9ceda4c245cedf69e222eba7fa595e0194faafd79160b6a269f15c71f722c19ad9cc03bf47d96d9f9ed8fb2b4e91e0d08fde6cc184ac44f450871b4094f721de14714f2e76a23e01ef18074f23e6364a05809065e90616dd046153649f23d0e1ef36674f6cb4d154cf71a23e396435c3976fa403deedd378775a5d5cb19fc964f4902f7c91483c1ad22f459eefc6281cdb2bb8ab4b65b4be36da069c996eb83312eb795d79b8d117bf466f1fb326762550970590cf7710ca0fbd08c2923d83ae89cc9850cb8f0f8ef4d6cdd10abacd7083f600e72716a2ced53fb34f0763859cc5c3e5c1070f4981eb3645ba51dc4cf939e770a5a6e91143e7ad178f3b62c580de426dee0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db163abf7c534f54b0af1ce499d90e47e32a24d1855ac6cb8da37c341b9b62e2f4579160b6a269f15c71f722c19ad9cc03bf47d96d9f9ed8fb2b4e91e0d08fde6cc184ac44f450871b4094f721de14714f2e76a23e01ef18074f23e6364a05809065e90616dd046153649f23d0e1ef36674f6cb4d154cf71a23e396435c3976fa403deedd378775a5d5cb19fc964f4902f7c91483c1ad22f459eefc6281cdb2bb8ab4b65b4be36da069c996eb83312eb795d79b8d117bf466f1fb326762550970590cf7710ca0fbd08c2923d83ae89cc9850cb8f0f8ef4d6cdd10abacd7083f600e72716a2ced53fb34f0763859cc5c3e5c1070f4981eb3645ba51dc4cf939e770a5a6e91143e7ad178f3b62c580de426dee0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1631df90e7883cda42067f185edf2cecafa23917b34aa82642dfacd8a437504338637279595d7c1bad51ac5107787b4291c9efddc11de4bbdafffda7569404408bdf9dc6afe5c3bdf47b40aeeb416bb98725f844a5d000d7ff61f7b19d3a2c250c33aa6e4a6ea2481fe52c3c70ad70e8bb4bccdf0b4a13717d5edfca8c79345fbc6ea830c6495be3ed5d154229abe36f1918b216ab25dc128af8ce76b637bafdbd65ff4882516a840594b8d03292c95633be3e1cd6c3b04cb1db22fa565de72673bcf4296e570b196f167ae12a21200b5e218285d78581bf1babb4c587a11acb972222c619b7b2637db1b3d163e690f3fb3c6789c6604c4e70d98bc9682f5f43c8551c185d5e66540351060913d86f8faf2e383ccfbfc8f5ab51d83298a06b9fb78c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16a4f5aa523da077847addbc613390ca2d81acb982c2a7f995888e2c67684330be684d4202d92fd52ecd90ee4de6c95c3ce1568435a00b50304860a0f0098c1f278f0720c3e0ccef91699a3abbfb56aee277e062870f3e7dcf789e106d2429d4a8d87c07e765978f9f3e125e68df55f1950051466da01238072085f0029bcfa1447294ccbc332195059a7ccde26a05f384c92756bdf62944879fde46dd32ce0a9341aaf69123193abe0b4f8fd1354b2d20c8cb8daaac9572748d1c047a8bb3a9dc225347d62a7e71a7aaefb0d175d68a457e8ecdc7636c6f594cd0f049a74154a09c26d9597189e6fa14e23da5f9c9c5759c5c6578f0b903226e368991d0e98d2de4de7aef65afccf2791939047723c72e0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16fc0f38c0358cae2909655071b8491e084859b9078e6059bfce3f699b824c801432b7ad51f6a0973767bcb64a5eaf77a7e1568435a00b50304860a0f0098c1f278f0720c3e0ccef91699a3abbfb56aee277e062870f3e7dcf789e106d2429d4a8d87c07e765978f9f3e125e68df55f1950051466da01238072085f0029bcfa1447294ccbc332195059a7ccde26a05f384c92756bdf62944879fde46dd32ce0a9341aaf69123193abe0b4f8fd1354b2d20c8cb8daaac9572748d1c047a8bb3a9dc225347d62a7e71a7aaefb0d175d68a457e8ecdc7636c6f594cd0f049a74154a09c26d9597189e6fa14e23da5f9c9c5759c5c6578f0b903226e368991d0e98d2de4de7aef65afccf2791939047723c72e0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1660fd5cbd891955b0b729a1949a10413c7a820b3fb2d116935a9188918046fc32db19cca5b0777ca1e1f7d17aeca3143e8fb1b3c2bdc0a1bfd887edd4dc1a0811a736b04916dfa2e0b9c8839aa238213b950980e6606b0b04ecb503ea0f08d40681a267c851a40fb35008f39e9f83ac777b2224c1f134865ab1807fa7d2049f9fd105a5b4bb2bb8551a9d7aa6710e12fb8e1ead97692b5f3701a0f9afaf6992436981e8aa045e9f5cf2cf6d2539f41e9da3f36f32c9fccec403edaa0e490299ef1bcf81c18835e58da99f4d8dff60d55313b7b5432b544a3bde1255a252f84305e21517cae46d03136d79c6caf5251f7f81813935d006d058d016741c4af8432d5d63d8c4feeb5f99a71d1af188372cfe2e383ccfbfc8f5ab51d83298a06b9fb78c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db166dfe00a78c8ce37068494218edce22008086c7f077d03c77978282336f71e00db5687f8fbeeba1d2be69004933f17c63bf59dda57d2a069f68ce0bfa5776fa920284a4aa77204743c7a69cc89ba79c05604615f953fc0942d79013271f20279513f8d6e20afc978e1b18f386b230d6c5ffff9ac4eb4da3bdbe662d466079298520397eb23b6c7a7b7233a37a2cb290613594880bc793016f45db12f72da42149000774e1344cb296eb74d98688b82aca890cd0c23da82c32f78228881331b5da93062bc4731ba561296f2009f47299d6586d5d4ad0cc62d2f2d375df543dfb3c94ad5d40073397b90942a98c68d2d1709dbcf11520b3876dd2bf936768a04e5cd52c246e5567e6428588414d0839cacfbcbce1a7488bf8bc6a1c9b4ddc020567418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055162a6c03584784b39109ddb6616e3afc81a22bdcf6440a1af30f6fc9a76d344a752d0e341f22ef60ba46931bbb86cdca6dbf59dda57d2a069f68ce0bfa5776fa920284a4aa77204743c7a69cc89ba79c05604615f953fc0942d79013271f20279513f8d6e20afc978e1b18f386b230d6c5ffff9ac4eb4da3bdbe662d466079298520397eb23b6c7a7b7233a37a2cb290613594880bc793016f45db12f72da42149000774e1344cb296eb74d98688b82aca890cd0c23da82c32f78228881331b5da93062bc4731ba561296f2009f47299d6586d5d4ad0cc62d2f2d375df543dfb3c94ad5d40073397b90942a98c68d2d1709dbcf11520b3876dd2bf936768a04e5cd52c246e5567e6428588414d0839cacfbcbce1a7488bf8bc6a1c9b4ddc020567418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e90551680c42856b215ffa6b045a2e977399f5a5b5ada05f76d070964c6e2baddf255dcc21e0f451a6a1aa329e654bf754fd38a1aff68da8a8955db0f625b3d0cd3ac4eec119407d3fd07ee0343419f34a0822e2f319e22720cf17138593f6c4d1c3a2f5342104f0965ae58876717392af92c8bd1f3b7e9896e32b68ade0c5354c03c1d3f8c9587cc2e93de882314fca5f0b1fd54166aad4027b705657136ea9dba0cb93f8f5cc14faaa5bd8a51729ecffa9aef19f9a921f29b923d7512a37e80a1529aff733094a31adfcf75fa6d153fb31d61df427f668403e74457f0f43e5c5000f0bb0ab9ccd1cf27958a261adcf36a57d424a0538bfb7f12c24907ea76b82f396cd52c246e5567e6428588414d0839cacfbcbce1a7488bf8bc6a1c9b4ddc020567418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055166608459a6d4db66bee030a90c046b56ee873553a56a44436000f4dc6926f32ba2f44b3916a8992fb517c37745f77014c354ce922b22cd91b03ccb08aafde15eaa1c7f1e7b339622866f76272378714132daadad4c87459503966303363414305971a3ffd34bc924b3324c4ba6e149e6db734831970cc5613b2142116f93ad3ca1be03b5289d3e82b4f2a42a8fdf4c02fc14107a24dd6ea3c5e91cc6c75628e14b29de1cfc2ac3bf2fa0a873de7f752f4b75dc6ac9259036e3f69d68168469cda6b55b97b2b8080e26437fb735451d37f022f4b4b3c3d126b541d8addb7c9323550405f54c612d4867df58afa0a022f3b8bca6d184accba0cb655807df543d4e722dc0818ac6d3b2f523c764abcc158cd76314f206cb489b8f9d7a8155740ee354a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16916f5ad95f9f260cb52a3d26cf67edcfe873553a56a44436000f4dc6926f32ba2f44b3916a8992fb517c37745f77014c354ce922b22cd91b03ccb08aafde15eaa1c7f1e7b339622866f76272378714132daadad4c87459503966303363414305971a3ffd34bc924b3324c4ba6e149e6db734831970cc5613b2142116f93ad3ca1be03b5289d3e82b4f2a42a8fdf4c02fc14107a24dd6ea3c5e91cc6c75628e14b29de1cfc2ac3bf2fa0a873de7f752f4b75dc6ac9259036e3f69d68168469cda6b55b97b2b8080e26437fb735451d37f022f4b4b3c3d126b541d8addb7c9323550405f54c612d4867df58afa0a022f3b8bca6d184accba0cb655807df543d4e722dc0818ac6d3b2f523c764abcc158cd76314f206cb489b8f9d7a8155740ee354a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db1605c02a0e111dc9a4ead17f60b074ba11e87e9f0aa58db579be1593462be73d0d1cee15aa7cb0ca5dbb258aa6b6e4d522ef0d226d59d29992b57bccfe9e2ebdfcd4010def2c0210fdfba5b3397721f2d20147ba314bbacda14657af15aec1949e0405b288e33d4f4581d774d7f2510e22dd21185b45b0c2d15e9581bf6dcba50ea6c9e8d0e67a72461aa199c9cd93a996ffe67016fce496973cea558e719d73870a986201ab1094d6f46128f6b81913db10e8d73a3429da21f99c2598d329b84d2235c43658ce6c16f9dc1abb320ba456e881808ed8f1405bdbe315ccabad8cd350405f54c612d4867df58afa0a022f3b8bca6d184accba0cb655807df543d4e722dc0818ac6d3b2f523c764abcc158cd76314f206cb489b8f9d7a8155740ee354a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db1677aac522a9d164a767bcedab48a9dd02abda3e4d60749d27f235d526dab86e2e3b3a3a0f4012668d1ab1b5682df119e819109bd24b8f81cd5519a66ebc5600c0dbd0bdd0ad813ac97cacac46df9b00d6d6c47e704289b88f968220e4086ebe9c4b472be28ec738c05db0a7c1903db2d80433cb2e24190fd15f4c0d0fb343c35281d94d8a600c38d3834152713397ea76bd8501532d3b15afa9318e3dd581310bb8d3160a08c95ad7475e3bdce91a7acb86a44a38254e9daf0b9d88b9a9e66d713028bb77d5cb770d0f2436777dfbcb20423d6e8e73a99af2470f3616439538c3e74f3c7b75281d3c66c482e1ec3cb62cc96bead246343ffcb7116d0c1e1417942b18092b6a6b7c52a14ed7322e4d629059014acc55d84cc67964867f2bb1a995a334746f3fd13cf79ff9db23ad7b218b61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16c8ef278c9c67fd9214e73548b62695545607b874134b4b0695a8d1e696a5f9f849bc0a891fd7f8102e4c6dae713367799e41c61db1da645b875de5f7c9f54525a871d8d82d87d35f048172acba4c1a8fd6c47e704289b88f968220e4086ebe9c4b472be28ec738c05db0a7c1903db2d80433cb2e24190fd15f4c0d0fb343c35281d94d8a600c38d3834152713397ea76bd8501532d3b15afa9318e3dd581310bb8d3160a08c95ad7475e3bdce91a7acb86a44a38254e9daf0b9d88b9a9e66d713028bb77d5cb770d0f2436777dfbcb20423d6e8e73a99af2470f3616439538c3e74f3c7b75281d3c66c482e1ec3cb62cc96bead246343ffcb7116d0c1e1417942b18092b6a6b7c52a14ed7322e4d629059014acc55d84cc67964867f2bb1a995a334746f3fd13cf79ff9db23ad7b218b61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16658d2054fbd95a86a7afd30f9a7aa2649763fde798433ea72357dbf13ec2d2dbc159e5279cf340f1dce5a33366b3c7fd04a865941ea04e3db938f5a3e3da782d3095b74cebb86b9cd0b99bc8d81367ed6c951c15178c0bd09bd856f7cd79dc0552feb789826f1498719dba55ae5c5d2e13d840fa148e7f13a2c4ba26a8256904057c9953ceaabf7454d3a59b0b657462e25bf16bc7b9c84483473d0f9a2bb4c0e4018edbbce146dbe3042c638db6f8c609f5f738d440b22795ffe3822f6bc03a6bd6ec26c181062e0089624248cdb8a11fae726f59db1e3c88323b3bcf14c0ee079daf37a74e3f1adb35256abdc261749763d3d4ffde388ab82ec7f9f849a83009b1c2b08995d0664444ddecca7af576a95872c5f83946fdec8b0a720bf19ef07377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e90551673e3d573ac512017a2549043885c9aedc382f13712b5faa1af507867476e6bb8fe689f739c923e2532acc17c04d27ef6cca7625fa718c6937abb642bb0d3fd7c33115446360054f34d9d6cf73fcfdfe06c242932a2ce4bba69813709886fa85da2266c9d94520bcb5fea23e66b1b183cd33dbb6395f9c94eb7d1627e770235257c215f120f0619f0ee2cddb4792a11511995c7801b8bc23ef1da3b4275634e4d998416de9c6fecf77f9d2852ac6a97f2331cae64f4dc7ecc942422738d35456e93d5acd55ffa4dd6807e2dbc564da64562afce6b773af5c1b467cf503bceb2f71584eecf960e89a36e2def754e0fe9a948fdcd23a6ba8a65d5d5323ebd95d55aabf884efd3d5ff9e5e1a415b08522829823ff41593ae6d7f892140acfd46875a18e073733dc9d2e6f1b4c473add199deda630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16198acdd75566a81b0d22f00a315dfd1fc382f13712b5faa1af507867476e6bb8fe689f739c923e2532acc17c04d27ef6cca7625fa718c6937abb642bb0d3fd7c33115446360054f34d9d6cf73fcfdfe06c242932a2ce4bba69813709886fa85da2266c9d94520bcb5fea23e66b1b183cd33dbb6395f9c94eb7d1627e770235257c215f120f0619f0ee2cddb4792a11511995c7801b8bc23ef1da3b4275634e4d998416de9c6fecf77f9d2852ac6a97f2331cae64f4dc7ecc942422738d35456e93d5acd55ffa4dd6807e2dbc564da64562afce6b773af5c1b467cf503bceb2f71584eecf960e89a36e2def754e0fe9a948fdcd23a6ba8a65d5d5323ebd95d55aabf884efd3d5ff9e5e1a415b08522829823ff41593ae6d7f892140acfd46875a18e073733dc9d2e6f1b4c473add199deda630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1661d32830d77515a63d272625bc2bb4b650f9535161cf28518433fd7e843a4626731d1b7ebc7d3ee811ad31af6ba6588e3d948374ed6a6457ea99bf7896d24fc024969d34c6606e1d8fbb75bd7cf337d420554a0b6373299d919432b1e32340ecfc445d8e60de4cd018bf42629273dadeb40a424926bcc7ea5f93ff6883e52f4e2728b1664bdc05aaf732517b52dda28016a8403495be0789fec71904ffc1439ee800190ca42f38df318acad1957e9526c80cc23652f865987b04cff4b7d1064fa7511c2bedd112d77a0506f587de1379cadd30d72b6d5efaad61764c80615c62c1d65001cd042fe0e62f02b5deb4276c81813935d006d058d016741c4af8432d5d63d8c4feeb5f99a71d1af188372cfe2e383ccfbfc8f5ab51d83298a06b9fb78c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16b730bd8cbc5a9656dfdd225f36ad0d7fc307f5914d759a25b24db81375e0e4bca49e94d8e75d59948de72683a00b2f0c910880d91a6f705c720402a8c5d67f95b5b508dfd2dfb5bacabfb7b6d911e6bb2b47b2868b0290fc1e54948c3dafb643fe90c6d45a580d9af137fc9ba98d50ad8f3c56c565eca8f1ccd159b248490f916732bd1f085d9769cfb0ea39a4a4ed1aa5600e5938abe79fc0311105c2ae70bf62a5214fc79385059a39503c49c654e7bba10e1810708968aa8f5c0d267e89ba7fc131e29138f5de01f63423a01a4e7d40e15a844ab27757be97c9c7ec1dd95d1899c36276125e0a1d0ed715016055350740e0624ba518c341e818b27e1f6bf45b022ac8ec0d6668add653fbfe99a4f7d9cf9312e2f207c53d965d4215d2bbdc1095570a1cb4592c11a77d62e566540f48f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055160c0753cbd37c66125a5731128c62ec76c307f5914d759a25b24db81375e0e4bca49e94d8e75d59948de72683a00b2f0c910880d91a6f705c720402a8c5d67f95b5b508dfd2dfb5bacabfb7b6d911e6bb2b47b2868b0290fc1e54948c3dafb643fe90c6d45a580d9af137fc9ba98d50ad8f3c56c565eca8f1ccd159b248490f916732bd1f085d9769cfb0ea39a4a4ed1aa5600e5938abe79fc0311105c2ae70bf62a5214fc79385059a39503c49c654e7bba10e1810708968aa8f5c0d267e89ba7fc131e29138f5de01f63423a01a4e7d40e15a844ab27757be97c9c7ec1dd95d1899c36276125e0a1d0ed715016055350740e0624ba518c341e818b27e1f6bf45b022ac8ec0d6668add653fbfe99a4f7d9cf9312e2f207c53d965d4215d2bbdc1095570a1cb4592c11a77d62e566540f48f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055162a744514bbf00436ec7ee004d3965094dd305c83a8b9c275cf2b3280af2331989f58f1d44672a118417316e26b4884bcec1c409464c2691334bf5b8f1e371892b7c763cba2146ee284f6ec7f35984b640596ee3385a88c33be2fd1eb422d8f58662785a6ce5e41512367b52895da0db72ca9a63957fe6f9204dc619d7c008ba0ab9b814b65251f9aade78e34974d3ffcc3e3617bd06e65b7c85e4717dbde0ac9e6c1aa6231efd974f784da1aa3479d2a22eeb889cc6002379b541fdbbb0f5449fcb6490744c25bdc43be9b4592403422032a24b093821d9f4aa26822767cd3e562643d4c8eaf91bb6d8a93100b43401c42263cd5aa63f46efaa3fa87452afe7c6b97af7e1b8fc9d4ac0fdc2f0a4a368bdf4095e3efd77d7da7399bdce812d61ced4916997c532704e74c8bf77ae7808293741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e9055164b8bd7b0cc2a24a07e7a80fcae7984e1c282c43681b355f384f05818ae03bc2136b0cb92b0ebb97a1029a0debdc05cce49e420a98f57ee19f19312816601a1ee0dfbbc8d7ac1040f492ecf208c41cb60421ea4c0d9100c4823ef8e189e2c0082a345de0e29d3d7f4aff50800215731b7880aee3bc653d38b3829441d350ce3b7fb049ef159f95a2e6cd38cf22f061bd20fd7cdedaad843a2743fc7a7b4cf941837c2ca72dc26e213cd40d11ef6ed0efacbe34118e84ec8defb53d3791d5661db9f3dbbfd754d9d12d346ba6008653fb36e4d99e06c6b4345f793e25693318577036ccd62dc45dea39aa6b3b1493b5687da41b40acd24018e8812878c25002b7da3fc7af41c1d5dfbe11d0caa1203abcd3a417b530b1c45d737f01ae5f6dd5bcf9337aa5a4a8e3505a36e8f28df7d089048f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055160c8a98d7ad6fcda499b53de5f201eab5141538a5a75412d7848772e6d5e3970cee6f82baef0a7dbb3134238917e7bf0749e420a98f57ee19f19312816601a1ee0dfbbc8d7ac1040f492ecf208c41cb60421ea4c0d9100c4823ef8e189e2c0082a345de0e29d3d7f4aff50800215731b7880aee3bc653d38b3829441d350ce3b7fb049ef159f95a2e6cd38cf22f061bd20fd7cdedaad843a2743fc7a7b4cf941837c2ca72dc26e213cd40d11ef6ed0efacbe34118e84ec8defb53d3791d5661db9f3dbbfd754d9d12d346ba6008653fb36e4d99e06c6b4345f793e25693318577036ccd62dc45dea39aa6b3b1493b5687da41b40acd24018e8812878c25002b7da3fc7af41c1d5dfbe11d0caa1203abcd3a417b530b1c45d737f01ae5f6dd5bcf9337aa5a4a8e3505a36e8f28df7d089048f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516f24cc1ade9ca41a0cc1ba6110b397acb5dbe57563de771c55c83bf5beed0fb75dc96497c013dff323da47075777faaa88fb0458020e9019ba26d2bedc4fa3e3d715c3e70c6bda57ac16b8646536faf6e4365920ca9e9ed126ff30ecd6a373e773e633d5da259c61b36ea17eee2a31b4ad539b5524eadaba0e2850c7196bc2805ad855a9c2fc0f0ace7a18ea76851dc67eff60dac5343696c1c6354e5cc77c7fe067c1d75716ae9c7e22dd0a442f5b692b05b28d3a9e2c5f5da1bd8ba6e898cbe7cd04e6507eb14f5127d67a72932d3ef6068648aa7da460f3021e88403975e83583cc926d6a3109316b0b7f0d6b33e020fae7aa17c3771a5d87655c177be63a3f1f3ff19d49e56b0039cca0d3dc79f8c823ff41593ae6d7f892140acfd46875a18e073733dc9d2e6f1b4c473add199deda630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db168fcebf352f1df364c402423419346f18d6a08585c0807975b17de53bde4297a3155a747ad18bdce704941a30f6ed5c6556251fb678a15e1c3b2646a1b3c4c6dfa52a730ce9d12f80c2a31ef5d082c6f4165378753819ea8513b71c1faa34c195246f58f9ff4468b8c6c3025191239225d130ba9a3b2ac8e5838500aafcc9e8e23acc55711f75f00cad05613e5271c2b1101eefffe9e403790ce8b579a5bf5c25e2a1c0f78612987a983cc7221e6ad22ddf06628df3521b8bad4beaf0b7dcfa9ef825d3a32217825c9bb7745dae3465d76b27242880108fa11d303345fd89f7e3ec305622d4aba0f5d9ed9b7954b7f7c393047b784832afc9581402d1e6c1863f9ff6c81521a1d1205201139591f3ac407cd1661ddd5c8a45f780561367c10ccc20bf4af69d8f8574cae37b7974a54b6b589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e90551635bafe4715dd9a9d43cfadbf5e3dc2fcd6a08585c0807975b17de53bde4297a3155a747ad18bdce704941a30f6ed5c6556251fb678a15e1c3b2646a1b3c4c6dfa52a730ce9d12f80c2a31ef5d082c6f4165378753819ea8513b71c1faa34c195246f58f9ff4468b8c6c3025191239225d130ba9a3b2ac8e5838500aafcc9e8e23acc55711f75f00cad05613e5271c2b1101eefffe9e403790ce8b579a5bf5c25e2a1c0f78612987a983cc7221e6ad22ddf06628df3521b8bad4beaf0b7dcfa9ef825d3a32217825c9bb7745dae3465d76b27242880108fa11d303345fd89f7e3ec305622d4aba0f5d9ed9b7954b7f7c393047b784832afc9581402d1e6c1863f9ff6c81521a1d1205201139591f3ac407cd1661ddd5c8a45f780561367c10ccc20bf4af69d8f8574cae37b7974a54b6b589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516d080671300acf349a6904aa628e5c7bc6c2e6a505f04199e41a1b1a2abda4df19c034ee92bf57ad93c7b2fd8470b387901c3c10502ba899d7255f19a222c459ee0b537cd90cc5448d9abf76c03339025c66ca4c7b91b34cfe588ea3e28e27588222a03f29574617a8a44518132c871a6b6957941ea646f17267363dcc237866e33acc85671283699a71fa07e41a56ea8abcf5f98b187787560e437ec8ee392087297c3bd923f95ae67e71a9feede2d699d7d20cc65b7015fdbadeb74af58247137981fb1b9fd90aba90f31f8408eae4aae2e52f82b6b2b9915278f4241f2522ec0799609307a7f1a1ec1f1fa3c52c95b7530696c9be519b26d154ea3fe2b31055b1dec8a97613069de0bbe1d9078c637d9cf9312e2f207c53d965d4215d2bbdc1095570a1cb4592c11a77d62e566540f48f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516d4a122ec60cdf9fe9571a85ae4f79051be41adf86729f6ce8b47033b042a42b052459e9f50579220653e747ea4da356fb94e19d7e4618f49f17509ce9dd81c28b472cc1ef3fc810481ac38d38b949954e89b6c505c6d05d3f7648adf9710d3edd2d1f6c3fce3c02fc1f5ac79cdee23d54beaa202028ae198a695e4b1fb4e1a3e917d30c66cec3e0d9f939e5789ab0ffb999f526f0dd0161c6ec6aa3adb3c1554c66a9259d3ce50826086c4b9409ab5a0c4be88a001fcc21469df1593f01ba12ff981ef2be05da2168a334a60db625cda08c76e3ffd376ebe302a387139b101d6c33f502ba42e95af6a977c40a662fb74819c116d90631d44a3d884c2d315371ba3fc7af41c1d5dfbe11d0caa1203abcd3a417b530b1c45d737f01ae5f6dd5bcf9337aa5a4a8e3505a36e8f28df7d089048f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516efca0cb9269f905cecd7dbdde0af36db2df4579926b2711d294221988e6f743b52459e9f50579220653e747ea4da356fb94e19d7e4618f49f17509ce9dd81c28b472cc1ef3fc810481ac38d38b949954e89b6c505c6d05d3f7648adf9710d3edd2d1f6c3fce3c02fc1f5ac79cdee23d54beaa202028ae198a695e4b1fb4e1a3e917d30c66cec3e0d9f939e5789ab0ffb999f526f0dd0161c6ec6aa3adb3c1554c66a9259d3ce50826086c4b9409ab5a0c4be88a001fcc21469df1593f01ba12ff981ef2be05da2168a334a60db625cda08c76e3ffd376ebe302a387139b101d6c33f502ba42e95af6a977c40a662fb74819c116d90631d44a3d884c2d315371ba3fc7af41c1d5dfbe11d0caa1203abcd3a417b530b1c45d737f01ae5f6dd5bcf9337aa5a4a8e3505a36e8f28df7d089048f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055166af3e497c2699409892b43279c922a3e998e9440f3241be87e6c0bfe3f6d3d433de98932026c8232235ca6a801c1cdf5ae8bd3381f9c033b7db36cda27791c7a238576dcceaf27e6ad098edca98d5368517ef4fd3d3433e5b541c65d99d12f3090616e7991f53b5a9dc4afd71d45d91245ae133b4f12432cc9fbe2d12e0c1a0b2dfe9df0445781754310d7433bd045ee2a70cde08b6de4672670aa44d672c831c9e01287cb3d637bd5bcefab45de26c576ffafa9c005c3e52eceac41466bd2ce7620dd99a27e6f019594e77398ea8b221a74976679e0984d4a8b0838da6ec896d35d8453d103fdbb812a90b59cc0478a6e8222768b9b5157d175df69379eb9c409b1c2b08995d0664444ddecca7af576a95872c5f83946fdec8b0a720bf19ef07377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e9055168bb3bf3d105476c1f6c7748a69347114d56282757e24abad819561c02594ba8c7e5f06b4b1892927a516e87c77b040b579924beac03f5982b51cfb16fde617f61dc2dcbe6d9f18a02b39a77bea6b2a1b6c3ae224a6f534a663772cd1d43b93544a5b2aedfc6ee0bb8f728f9f7300ba7ffafec577916d4fa46a550df137263e488b18cba4582987f1c3754735e94f69bf204da060da218583dc1bde64f9bd3a19b73edf144ee8e1a37e704e34ef837445bb9664d20fd056d256d2d44aff7e80de16be0cc08e233b909f00da53a21889c494889d42616866e10a85151af2e43bc8edbf5ca1ea6e88ef128452455e197c7bc506abb191c58fc3c42d721e9a197f15cddd6811c6559b3aa3730a4709eb1e9dee2af61dfa38f1d77c3f0027f89b2377e215786999fc404c39823fd1ae69998b19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db169359b6770a12a5a885d9eb2888826a6672a7c3d21765a2e3f24d91fb2637834b7e5f06b4b1892927a516e87c77b040b579924beac03f5982b51cfb16fde617f61dc2dcbe6d9f18a02b39a77bea6b2a1b6c3ae224a6f534a663772cd1d43b93544a5b2aedfc6ee0bb8f728f9f7300ba7ffafec577916d4fa46a550df137263e488b18cba4582987f1c3754735e94f69bf204da060da218583dc1bde64f9bd3a19b73edf144ee8e1a37e704e34ef837445bb9664d20fd056d256d2d44aff7e80de16be0cc08e233b909f00da53a21889c494889d42616866e10a85151af2e43bc8edbf5ca1ea6e88ef128452455e197c7bc506abb191c58fc3c42d721e9a197f15cddd6811c6559b3aa3730a4709eb1e9dee2af61dfa38f1d77c3f0027f89b2377e215786999fc404c39823fd1ae69998b19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16fb0a6bfd36c79c205ea6ed013eb4a7c3dbb604dff6819b2ca5887641a6265dae5e2f79bb23a2580d7d89539ebed147bd1074cb536eeb0d1c23301d242bce95ba57dbd8fb1e49382e779d681b553a2fd43c045ee753d256da63786d01f3d873647fb7f7c9cdbc4e90acbf331a77b177bf844c1008027f7cd57b8b0c91a6e12a008b42575a6de86e05d983544edebcd10699608b98123dfb087e41e5720a2403ce90911927689c3ad50eda2bbed9d2605f24fa6f91770d3d1e60166dbf1f96d25ef43d44f203f28d5d301de13a33bbd30e1b842d95bc108df3276dba9145844e43e67d3be1df8dad26a40dc4f819452db2449fae1ad1928138c48c0d4f3b43ceab7d6ff98184b74dfb108312c63815214ad66963ab2f621bafeadb3aefbdcba741ed4916997c532704e74c8bf77ae7808293741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516fd05cc8236c0d9c4e195c69d9e3f43af2e06c653c1d4af6aadda267a36c2e98f24d5b1442840423ef00576ae772f1d208057c4e11ed8d9ba4d9646d20312440da8969c5dd2c4f5d2acc983d5db5efef39ca786cfd1e24451cb02272775d2f11acadd5b2bae8efabe17542a24c9361f2922c19ee66b6e40b4a371bf9539aaf0644d54c8b3ed28921c668fe13ff9c688c95b341339656caddac1424c95578380afbccf05984251b3859ddde01c1a769bb45c25c3988b5387891550f5b58849e0bab083ddcac9a4f73a26f67cccc5d3ba4428efb3ee85defed45576948c7bd96aab0f92ff038b7a09efc4e5acc4eaee61058e17ef92f9d76f069399ea405713a1247ba3b8959e7b87d768d0b05cb7b7acc07cd1661ddd5c8a45f780561367c10ccc20bf4af69d8f8574cae37b7974a54b6b589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e90551619db45b31c3aecb76002d5a9943d895ee732902548db0478509c2832280a227b24d5b1442840423ef00576ae772f1d208057c4e11ed8d9ba4d9646d20312440da8969c5dd2c4f5d2acc983d5db5efef39ca786cfd1e24451cb02272775d2f11acadd5b2bae8efabe17542a24c9361f2922c19ee66b6e40b4a371bf9539aaf0644d54c8b3ed28921c668fe13ff9c688c95b341339656caddac1424c95578380afbccf05984251b3859ddde01c1a769bb45c25c3988b5387891550f5b58849e0bab083ddcac9a4f73a26f67cccc5d3ba4428efb3ee85defed45576948c7bd96aab0f92ff038b7a09efc4e5acc4eaee61058e17ef92f9d76f069399ea405713a1247ba3b8959e7b87d768d0b05cb7b7acc07cd1661ddd5c8a45f780561367c10ccc20bf4af69d8f8574cae37b7974a54b6b589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516b3279fe3458109baa2b5f04dc1733ff34c6be76168329ecd01a019d7df9ebad57f83be88f0b007b14cf711a4dcdafde5f60d3dfa6be9d1493aac04824878a84b9822e725c81c8e914dcfb7194da14a5bd9fec5c09e3ad50bd18957e7a57858c764b5c665d7bd10365ca8e400a73dab3c235b2acbac5b69f87fc46c34c324a10ef6db46f68495ef917074036eaaa61621f8a66030ee34e526af0c1a887cb13826891457b454742c35eb204c9c056da5af8df7e8182cf52a2834d45d2d69eb975fea1118b28be49dffede869e540202282df4ab8a1546861ad22ec7d3b99fe4a5bd9ef7fc4fdca2450b64e6eba87d3bcb2969cd61be54896a28ba69c891efbd1afdff7716c6782449f85e964e8d8a1c360984002122a3e13164440259c690fabc0b20423d2d991f37aa6264ec56a5b6c4f61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db163bb59e791d08331a368232981e6819eadcdcfe0a7c69f15d65de50818b2b539003e914a3d4ea74477644e76df0aa0abbe10b366804978e31d298a33e5c16ae0fdf542aa7a5f92ef1acc78aa7f3d91040cc62772e720c2b61e46cee177ed294a39efbdd3a9457aaf0de2ee54f89b6af311c16b9492e3f298395446d727e726dd8a27b8f703e6cbc8fa6c5ff738ee9190dfcecce590eb2c1774ac55ce67f7b970bfdd894a0e33f1d4add18b937e50a887170113ebeba914278392d6d2d349628321cd3f56b00d250a76fbf173ed6e8f3e0122fcdfd09aa738e2cf0b72fca16634262643d4c8eaf91bb6d8a93100b43401c42263cd5aa63f46efaa3fa87452afe7c6b97af7e1b8fc9d4ac0fdc2f0a4a368bdf4095e3efd77d7da7399bdce812d61ced4916997c532704e74c8bf77ae7808293741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516f827a228565d2d114aeec92729f7da91dcdcfe0a7c69f15d65de50818b2b539003e914a3d4ea74477644e76df0aa0abbe10b366804978e31d298a33e5c16ae0fdf542aa7a5f92ef1acc78aa7f3d91040cc62772e720c2b61e46cee177ed294a39efbdd3a9457aaf0de2ee54f89b6af311c16b9492e3f298395446d727e726dd8a27b8f703e6cbc8fa6c5ff738ee9190dfcecce590eb2c1774ac55ce67f7b970bfdd894a0e33f1d4add18b937e50a887170113ebeba914278392d6d2d349628321cd3f56b00d250a76fbf173ed6e8f3e0122fcdfd09aa738e2cf0b72fca16634262643d4c8eaf91bb6d8a93100b43401c42263cd5aa63f46efaa3fa87452afe7c6b97af7e1b8fc9d4ac0fdc2f0a4a368bdf4095e3efd77d7da7399bdce812d61ced4916997c532704e74c8bf77ae7808293741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516f41a8504b8ace94e11d53a3b4fec09dd135b20f3db6df6449dc95e7349ebebd9f124157511a3d6155ce0c2f01ab660c8eb6139f7d78de38650c7a2db4eeb281962f13609f89304281523b18c4ae508e6f425504db172a45c56be4b12fa54e64e84ff65189d1c4f200cf30b30d09269ab30bbd0454ee4820e7d76857d6a981f17ac6b4964ac91fb6fc0089473efd97516fcecce590eb2c1774ac55ce67f7b970bfdd894a0e33f1d4add18b937e50a887170113ebeba914278392d6d2d349628321cd3f56b00d250a76fbf173ed6e8f3e0122fcdfd09aa738e2cf0b72fca16634262643d4c8eaf91bb6d8a93100b43401c42263cd5aa63f46efaa3fa87452afe7c6b97af7e1b8fc9d4ac0fdc2f0a4a368bdf4095e3efd77d7da7399bdce812d61ced4916997c532704e74c8bf77ae7808293741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516a60869239ac16fcbfdd42719d5d045644c9f6b1440dcfb828e5565d36987d3e93449b5021499fcbe24368986e1722d5da630f8f2f0f59302f9520b04a41ad2294b200474325f58cc9fffae98883abf51d5a777360115396527c7bc997c20c33f6e5bf6ea99479d2d8837897ba0bc5a98b5853ac934749ebb32b40ea643b9b59095aa51450361bbfc79d2dfcb5ad9d51423ce19572ef5ad0fb64848633e140fd20f144f1e8dc3ef4c250e941e24be64189212f70a02762f8e6d66a8c5ef47eef0079fd7b5453642b7f55f7269902e849a28c6642d67161c7edaffce52e2fe291d084a605004ba7617131254239d6f5f1971ba732a832db9e4249e4175e1ca99ebe4de7aef65afccf2791939047723c72e0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1674e42938ace5c3f65e6c91aae6fa0aef4c9f6b1440dcfb828e5565d36987d3e93449b5021499fcbe24368986e1722d5da630f8f2f0f59302f9520b04a41ad2294b200474325f58cc9fffae98883abf51d5a777360115396527c7bc997c20c33f6e5bf6ea99479d2d8837897ba0bc5a98b5853ac934749ebb32b40ea643b9b59095aa51450361bbfc79d2dfcb5ad9d51423ce19572ef5ad0fb64848633e140fd20f144f1e8dc3ef4c250e941e24be64189212f70a02762f8e6d66a8c5ef47eef0079fd7b5453642b7f55f7269902e849a28c6642d67161c7edaffce52e2fe291d084a605004ba7617131254239d6f5f1971ba732a832db9e4249e4175e1ca99ebe4de7aef65afccf2791939047723c72e0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16417a0562830d4b64013c9de2991ab34d207e1ad4ee8f1aef53d2ee36ce53e10ae1eb4097c7bdfe15a7265ddbc004b1ad0abf51e274f1e48b28728762d1623b37d5ea3e9698f0275872b67589ad8f547f88c5ebb03bced5280129a69b2ad612a91b6487072d5392a9850e64bac5da93ad8f6418aab819dfce7fbfe66f8da1e68ed27efea7f56df6c48cd63ae6302794671eb5ff509815105dfaab499cdcc85b3103211213955f72c8234e4c604425db6c4eea21f2902f8ba25db7105e652f92eaf7a9704422e48c9ab636c02fa74ef19bc8af0b095147155ecb8636e94b4158fc9bf25834d8e0d0e61fc1248d80df485e3c6789c6604c4e70d98bc9682f5f43c8551c185d5e66540351060913d86f8faf2e383ccfbfc8f5ab51d83298a06b9fb78c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db165b2c83ec092fbc001a07b1c9f8168948e01bee728b5f518d995b070292442456a3e27456ae0927e7f56d8ac3102db9ce889f3a464cfa9ffb39a2d89438396915d52565520dca8812baccfce34455f0eb044264d8790f6800270ea736b3edf58cb15e67de3b96f6b0d03121b8f66a575ce530e5caef1d0dcae95fe705b1cce609700b026c9c6089ac8e705c16bf334c9ea8c9d23a2ce01832f10bce6e4dd946f530993822ed3f5a9dbec3cbae3cc1da9e2cbb28026b29841609f76dab30a86a615329aa035fd8933cd20dcc8d55e10a17e8444a9431525b6d201ea8a8b7c1a1fb511fc5ee38c675fec51cc1cfe86ff080960ead9302c13f2ee214c1ebb501678b20f18c170b92c19a210ff4cd997a7763a95872c5f83946fdec8b0a720bf19ef07377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e90551609b0633ce938fb2c582bfb0714bdd040cd9677963edde5dd706898d5fb263c5587b9210dcbb34504fe96f11d3d6cf442889f3a464cfa9ffb39a2d89438396915d52565520dca8812baccfce34455f0eb044264d8790f6800270ea736b3edf58cb15e67de3b96f6b0d03121b8f66a575ce530e5caef1d0dcae95fe705b1cce609700b026c9c6089ac8e705c16bf334c9ea8c9d23a2ce01832f10bce6e4dd946f530993822ed3f5a9dbec3cbae3cc1da9e2cbb28026b29841609f76dab30a86a615329aa035fd8933cd20dcc8d55e10a17e8444a9431525b6d201ea8a8b7c1a1fb511fc5ee38c675fec51cc1cfe86ff080960ead9302c13f2ee214c1ebb501678b20f18c170b92c19a210ff4cd997a7763a95872c5f83946fdec8b0a720bf19ef07377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e9055166945419cd857c56d3fb58fa65f03b6b9117eca7990ecc50e6591733e0d35c4656621a22036c8690f326d98cb71b7525623d782b6d2dce39680a0b3744aa813f79db7e55c35757f210adeced18e9a4c4d739f6d866d89a651488093c9b67ce365b6728ab53ff3a29619720c0c3a59c53b96e218525c41d7861666f9fdcff6c026889571c0a37e3316bc322bb9af81db8ae4c36aafcb363df5cf939a3b71e9be9efa61c2df72a7ec3fd9c25a98d3e874a479e016a861bbabe75235c2cc95a5dc1a2b9015001311a2ed70c90558f2f6d2c518652447fef644d2a2f37e243edd83eed7e07b7befd774cfbc049e4bff9507c139a65785b5b5ce642c1c53f712ed9bf520f18c170b92c19a210ff4cd997a7763a95872c5f83946fdec8b0a720bf19ef07377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516e7b790cbd62f4d2d102bf632c9802aa7b1a6c97434616c40c62721f9a2181773ea5345c3ebb911f4bc3e96bbcb188c7ef1fc2e09fe5ead95ea4494166e81d4e091c4ad41722947e8bb72fb8c200a23102bb46862d7709c9270fc8315494638eea38155103de17b70605a7eb9bca9fc0ef407f1c8077ed8fc4638ff22105a44dd225151f67b93501a3108e53b38689a8e45f98f19079fc4fafdd38d50fc180d35f8e7556a6b8f6e4d732d5dec623a9f3ec4696689a08170ffb47b2cad1950f5cdcb331df33edd34db72263fc75b6ea4197c2782f21f02c6bf7f899cdf813ac26c940129262456212f55b5a8e06560d3422cb780252e9ba00e1f010f4ea0e9622aabf884efd3d5ff9e5e1a415b08522829823ff41593ae6d7f892140acfd46875a18e073733dc9d2e6f1b4c473add199deda630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16e53fd900bd5f3a715612c9217dea1736388bddeaae4e5a57abf44bd086520733e008aa8b8533c1991826ea148a1c7290f1fc2e09fe5ead95ea4494166e81d4e091c4ad41722947e8bb72fb8c200a23102bb46862d7709c9270fc8315494638eea38155103de17b70605a7eb9bca9fc0ef407f1c8077ed8fc4638ff22105a44dd225151f67b93501a3108e53b38689a8e45f98f19079fc4fafdd38d50fc180d35f8e7556a6b8f6e4d732d5dec623a9f3ec4696689a08170ffb47b2cad1950f5cdcb331df33edd34db72263fc75b6ea4197c2782f21f02c6bf7f899cdf813ac26c940129262456212f55b5a8e06560d3422cb780252e9ba00e1f010f4ea0e9622aabf884efd3d5ff9e5e1a415b08522829823ff41593ae6d7f892140acfd46875a18e073733dc9d2e6f1b4c473add199deda630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db167dd152ee3a6eb4d2c64e12de42c51a6187d2c05f53d85eba1ed274d070cb2ea3142d30d9271028f76064b1897b49bd3feb0ca9188112fd8ca525253ad1dff49ca909d6f5ed5d85ff67987f4bc93a783f25e752b3323e6916e7e04d293944e7f1eaf56a15ff05465804168540f74375977f83222bfeaca923ead8ee0edd2db06bfa1992ba0466b9d2ce12bacc3b24e216b9494e6e2b3b8721af5985a8149707faf8e7556a6b8f6e4d732d5dec623a9f3ec4696689a08170ffb47b2cad1950f5cdcb331df33edd34db72263fc75b6ea4197c2782f21f02c6bf7f899cdf813ac26c940129262456212f55b5a8e06560d3422cb780252e9ba00e1f010f4ea0e9622aabf884efd3d5ff9e5e1a415b08522829823ff41593ae6d7f892140acfd46875a18e073733dc9d2e6f1b4c473add199deda630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16559bdf596138c35c211cce5be7b0ab32b37ca9f0764e73d6f69abc8a6139dd7a167fdbf642f1106bb363879ae910138ba851141e43bedd21545262d0d90d79f0d9850f09e87a4a6a4d3efe992ee00f05d3da72ef4e0a30afa717f3d8bdf35ead44a65f7310e2f35727152cd39e06236885757fc9334b1a76a8c747f9ff0a0a046918ccb32827e04126b65c02215d61d5473e7620861a7774aef49e5aa2c9ca38b0a411c28df9b33052fa427c95fd3b022877475d2b8aae3ed992ff7ede2e03c81c11adff4fe0d6664aec79c5e6f24d71287772e852d18497d6fbc97ce6bfb60ba5fdb16568143c0e4c32601c42b56714da91e7e652ac92f5399b1979cf1f036eb9bbc15d04223e561065be08c8ce399c406a551269b5f48a7c14269fb1008fe182cb55102e48c8595506b72cb557f41a19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16cda2c8df159b9c307ed1fe53812fabeac6ceeb972c14269dcf1f52597cf33c7da60b559b7ba74bddfebf979eb74fdf44a851141e43bedd21545262d0d90d79f0d9850f09e87a4a6a4d3efe992ee00f05d3da72ef4e0a30afa717f3d8bdf35ead44a65f7310e2f35727152cd39e06236885757fc9334b1a76a8c747f9ff0a0a046918ccb32827e04126b65c02215d61d5473e7620861a7774aef49e5aa2c9ca38b0a411c28df9b33052fa427c95fd3b022877475d2b8aae3ed992ff7ede2e03c81c11adff4fe0d6664aec79c5e6f24d71287772e852d18497d6fbc97ce6bfb60ba5fdb16568143c0e4c32601c42b56714da91e7e652ac92f5399b1979cf1f036eb9bbc15d04223e561065be08c8ce399c406a551269b5f48a7c14269fb1008fe182cb55102e48c8595506b72cb557f41a19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16a977ce51c7e6d508748596419bf3a86e836ba60f90b7e726393754ec7f61ffbc8fc6b2586cc6cbb58efa5f2e6b97deed049c0f6319d85b3fa2d5e7cb39883b26d859ac283b06c019573c7448be878d6702064373f4b54872a430e5fee6dc6966dce61901f6d62ba6f04161357bb71003dd0c8f57e4c32132eca97dbc3e17e67432b83d245f69617cf12fea44eda8f2f8310ccc9ab373228bc56e8d9fca07833cca57231bd6c07ba3cee385480bda5035c427355e722dbb1ead13bfd94ee67530b882b982af01169ebc46e63137f39052d4fc93671c9df51eae9682ba4c38649cb24776b808b4b58f066492246736a3ddd42a5d76b0bb270281c43ad71583e122dff7716c6782449f85e964e8d8a1c360984002122a3e13164440259c690fabc0b20423d2d991f37aa6264ec56a5b6c4f61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db164ee77ea5e7fafcd1eea4da68d4d42c6e251a8dc880b237919583be08524cbe720a5e74026a9b15388009ac85f9cc98615db654702ab425b70aac55a37aa9e5080348671a4072c18c8e3864dbb462153659c7e96a35066318f11649d38f811077108e6abc60ec0436e5510ba4f6655d65f250d5d11c3edd49784947413dee489d14739a03a9e68aadbeda5b62151af763d553102f76e0e302a4253922b0e8b66975f32de2d358eaad0c9559cb3a68f079887b2ce0dee32efda57f4aefa30b89783e8ede992f677420e94de00d197c83c3423d6e8e73a99af2470f3616439538c3e74f3c7b75281d3c66c482e1ec3cb62cc96bead246343ffcb7116d0c1e1417942b18092b6a6b7c52a14ed7322e4d629059014acc55d84cc67964867f2bb1a995a334746f3fd13cf79ff9db23ad7b218b61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16d66403f613f2607c781c0dbcc01116ce251a8dc880b237919583be08524cbe720a5e74026a9b15388009ac85f9cc98615db654702ab425b70aac55a37aa9e5080348671a4072c18c8e3864dbb462153659c7e96a35066318f11649d38f811077108e6abc60ec0436e5510ba4f6655d65f250d5d11c3edd49784947413dee489d14739a03a9e68aadbeda5b62151af763d553102f76e0e302a4253922b0e8b66975f32de2d358eaad0c9559cb3a68f079887b2ce0dee32efda57f4aefa30b89783e8ede992f677420e94de00d197c83c3423d6e8e73a99af2470f3616439538c3e74f3c7b75281d3c66c482e1ec3cb62cc96bead246343ffcb7116d0c1e1417942b18092b6a6b7c52a14ed7322e4d629059014acc55d84cc67964867f2bb1a995a334746f3fd13cf79ff9db23ad7b218b61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db166acff58f7d15d8c14b71fd8162709ce6606c19522d5016888ea2bd0a1b8320b50464221eae9826504d9b1ecf1b94a48405a4e8403f4c42e1aa9c7ed9a8dd4f1fb4ec878aa55717e5ffc772e2e7bfaacc1d40c721bf64244192fd949bb791d78114408efed83367296296592ba4e465d8531e9c0f796c6bb8740a8077ed438ba3bdb44abf2e04bbfa7f3be2e7a638fe568c3617c2bba99bf57ddd47b522264fc16ff0c5729dd3cdcd18d57e38c0ac19edfda3f4e5936e061f8817d47b90b34262402c095df77d43821154b4f24e9a6928af0de254d106aaaa8687fed340fa452c9b4585af340006b115455aa68eebb8fdfaa48f4440ff705d385caf7652c3b5156de3b174b6fbd9d12fe36d3b8c38919c061107b705ee72ca36f0359d4ebe985bb697ad59211b61e6abb9f581d9668feb63da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db1676002b364cca379b38f38cea4b1707c0022e90864670c6542878a2b8d058da558b1043650de874dca03ad0aa6cfe8cb33fb6153007f5967cc433cae35e29b376019211a69f758c5f1f5c0a739b68cbf113a035decbdc48eec7ef7867ad9ad1a77df15fc1d925c61dd6f71051b95fbf3953e01f9460a0f8acd00b4baaf9d1ad31537324bc04c17fedd45a3fd72ef29febdb48adf646cb6bcc28dd1a355c0e957e63f51d99674459453b2872df747d3c2e711f10e90ea02be241c171ee66072153ff2a4813642baf9272aa9fb6b51f8ae2ead84ad09c06664b798ecab870f00fb3eff749bcc424aad093a26ecd5c0eb9fe6e62c339b015cd429327b2f713d6d3552a77321f8452e9c256f82d8548a6652c146b84e39a834a5776d91641ccb02c2b1095570a1cb4592c11a77d62e566540f48f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e90551685b690a1c012d3377e67e6ffe700e20e592c454b3ed2ebfbeac3a69b179c702cd01f9e55f86b69c54a97ed61fd864f503fb6153007f5967cc433cae35e29b376019211a69f758c5f1f5c0a739b68cbf113a035decbdc48eec7ef7867ad9ad1a77df15fc1d925c61dd6f71051b95fbf3953e01f9460a0f8acd00b4baaf9d1ad31537324bc04c17fedd45a3fd72ef29febdb48adf646cb6bcc28dd1a355c0e957e63f51d99674459453b2872df747d3c2e711f10e90ea02be241c171ee66072153ff2a4813642baf9272aa9fb6b51f8ae2ead84ad09c06664b798ecab870f00fb3eff749bcc424aad093a26ecd5c0eb9fe6e62c339b015cd429327b2f713d6d3552a77321f8452e9c256f82d8548a6652c146b84e39a834a5776d91641ccb02c2b1095570a1cb4592c11a77d62e566540f48f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516ea4c6a14cb367251fe24754fe92fe0e7b73d878d326db3a2cbc336ed42b0819eb0b43663867823a9410649dbb02fab91f9c9c0bd416f2bba2422e043d43a096a31857cbef7bcc3ef6e062e51e82767270b1b25bbf13fc6361d9fa3f040527ea2e34f02b40ce514b1abefc04dcadd8e5cc5963274398c97e67715a793f92c8daf0230e146e0376bf18027ae833ebaaf37aae90c20b37bfb53a780cac57e31b454e8976e54df572c3b10dc8373c2b489f06d14e4b4e5e722763a8830f21ab2a558a563afee3c169f4370d5a256317379ee6bbb849d9caa5b156bfac880a0981d323f1d487527b138e443a3f70e57c885db3b744d1bb3f19ded6658e1aec9356f9d6b97af7e1b8fc9d4ac0fdc2f0a4a368bdf4095e3efd77d7da7399bdce812d61ced4916997c532704e74c8bf77ae7808293741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e90551651548f4b23beef68cbb3d026cfc8c9492cef94bd88b2d71fd77a5af5cf306773e7bcf1b9ddb885f8fcd8c4b63c92bdec08ebbf62c85b7b8901d7fc84621c297ce549a2a3eaa650af2665a7a06b10eb6639abe7a1d88054f783f0afa539120fe24e33e6e77dc2d43db11897e4b47cd04f7532a6f3dd026f5326aceb45a48eeae2e0de44151b2175503f5d9e8d354f500fb8c18984c73f77384b04ebf6dfc65f154b1436c9197b6117d5ed81d4691d8b721e61c44fcc354fea5291fe818aaedb44c9c0aebf1a9e1662c70ffb023f2407dbe8eefb59f5358f1113bbbf7468ff97eadcd6fa76548963da8f8de3ff84d1250308bb073e5c2e49e0bfa0324fc87f7535cd7eab2012f6a2538ecad89c4caa0d4a76314f206cb489b8f9d7a8155740ee354a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db1615227427ae43c0c7c05af2b1fc56468a7216548625f8ec6e175949666ace46971f9d995a6a7bf69fd846c29147188f4908ebbf62c85b7b8901d7fc84621c297ce549a2a3eaa650af2665a7a06b10eb6639abe7a1d88054f783f0afa539120fe24e33e6e77dc2d43db11897e4b47cd04f7532a6f3dd026f5326aceb45a48eeae2e0de44151b2175503f5d9e8d354f500fb8c18984c73f77384b04ebf6dfc65f154b1436c9197b6117d5ed81d4691d8b721e61c44fcc354fea5291fe818aaedb44c9c0aebf1a9e1662c70ffb023f2407dbe8eefb59f5358f1113bbbf7468ff97eadcd6fa76548963da8f8de3ff84d1250308bb073e5c2e49e0bfa0324fc87f7535cd7eab2012f6a2538ecad89c4caa0d4a76314f206cb489b8f9d7a8155740ee354a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16aa29681da2aa1243ac18409614111e1d543ab5cb4e54b42fda8f1fcb5a78da49ee736dcb4c7306abfa20893092623432eae8c831f31871e9d8a5f39ea0b01a2b1dc33bae43b36d794768177e7e04842b85feb0487ac9bd2d2bc5ae4585a3020669a3ed8444ae57a65401573ba4dfda51a6a504934b275862c5de7e4b9e704743be619f2de43b4dd86d44f515b06a2914c76d65a4e3bdc44c8144a507c4fdc40e042b8c3ed7817d8bfbde295ac4923b42a82b7ea4eaad2161f48c7a53342519ae40bc8cafe0f77a0633d66e93d2058f56ff604559d3963d0866e0bb10e962b72ffe7ece31fb3c469ee7e12577a9e02bcb8994558dab68737d80350dd8b735badacd7eab2012f6a2538ecad89c4caa0d4a76314f206cb489b8f9d7a8155740ee354a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db169276922bcdeb2b9da5df6e79bce2e7874a7e54b9389ce128e67b73995b13e4c8da35587653055de5a2092a9889ce3a31eaaca7867a91c6148a15085eb5744a58089b36edfe113639085d0bd22e14755c403f9820bb47f4a451d1757628f20e460975727559de05a5d99ad9abbf5de0c5f2d7b70d918b3f668e45c969a35408f540fe3d2adda6a342b2f0d6f75d1bb7d5633dbc118ed19997c9f372ce3670561d8c90abc046f211be3dc2188bbfab8303cd01fcf7ea62781dfe95f5378906ae20ff3428ce287750ef5276a399bed1ec0b5203a7cc0de279112913062d5c19236361979213a76fc4038e36fe430fd1198120d10a03999f97b08c248bc2eed64c66191d5cfc4a972ecb149777191502a02f0457d66caeb4fa6d95631b3c6750616066ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055160ba3caee707eeff0e88113b455639fb32192e21ae1bc478b02de368995d3c599da35587653055de5a2092a9889ce3a31eaaca7867a91c6148a15085eb5744a58089b36edfe113639085d0bd22e14755c403f9820bb47f4a451d1757628f20e460975727559de05a5d99ad9abbf5de0c5f2d7b70d918b3f668e45c969a35408f540fe3d2adda6a342b2f0d6f75d1bb7d5633dbc118ed19997c9f372ce3670561d8c90abc046f211be3dc2188bbfab8303cd01fcf7ea62781dfe95f5378906ae20ff3428ce287750ef5276a399bed1ec0b5203a7cc0de279112913062d5c19236361979213a76fc4038e36fe430fd1198120d10a03999f97b08c248bc2eed64c66191d5cfc4a972ecb149777191502a02f0457d66caeb4fa6d95631b3c6750616066ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516f7c5226fef543c7b97007fed7a3fa491304d68286f20cb3fad81faa8bed473cbed272d4933d5b7f1683aaf50da90ab59e6f338fe74026ec00156c0491a63f89c81cd5502a6327516e75581077c33eae2b03692f796a0e5991cf151fe2ccfb45aa6af31a72bd191e00f906e9f3b35ea0732299350064103bb80c3292bc58db0db67da5fcee375d0c9e5899ebb2f722b20b779f5b68c375c6f837dfdb1a5080ff6b7f4ec56ae8d42c0ce42cf28c6b3deb419b6c652179d23e4c995c9d9521ac69c2ffbcf1cefccf285f6a0e6c66bdd79d126d317cf79d6d86f217c194b7b539d30a7e13d66655d616651595e82c34a7b9c2204f8cfa7bc5e54250450f4a38f08caa6e91143e7ad178f3b62c580de426dee0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16ed9bb5567fa489ef7a5611487e64a0f6fb01ec19e2ad04879f69245eaf4a7e46a5d37c5e29395a43818e0d978fa0ed4b4279566381520f6bd40de349a1a859034cbef9796bf641114dc0247d15f542404c7a5f8cae65f5e665365b498a97896a2052f02fce283f6c7aa3f05d5d99cb6d2e6d884a804159e7dc621fcd7066b1f8fb9caa95fdc09533419c4a110ff7473db0e543392699149fdfd565efa805f4ca671463d10a5a8d1689d0c1e12c5abbe4e89700bf5d03c7cf4c9bb21b78c2cb18b140dae46ab8ea5be12e1ffe496d835d302db86b41cf7c6bf622facfc353afb469f4e3e41f7362768cd68849cd5968f07530696c9be519b26d154ea3fe2b31055b1dec8a97613069de0bbe1d9078c637d9cf9312e2f207c53d965d4215d2bbdc1095570a1cb4592c11a77d62e566540f48f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516738885a51b2f69831fdd6a161ee204163bfe5a1b0344f333915733fc8fae6d3e78a505cb1af819c73b52aba4ffefffea9a33d57401d36aac1fc12ea07e93d3e64fc220972f7e7dbb8b0688ba528ed3d84c7a5f8cae65f5e665365b498a97896a2052f02fce283f6c7aa3f05d5d99cb6d2e6d884a804159e7dc621fcd7066b1f8fb9caa95fdc09533419c4a110ff7473db0e543392699149fdfd565efa805f4ca671463d10a5a8d1689d0c1e12c5abbe4e89700bf5d03c7cf4c9bb21b78c2cb18b140dae46ab8ea5be12e1ffe496d835d302db86b41cf7c6bf622facfc353afb469f4e3e41f7362768cd68849cd5968f07530696c9be519b26d154ea3fe2b31055b1dec8a97613069de0bbe1d9078c637d9cf9312e2f207c53d965d4215d2bbdc1095570a1cb4592c11a77d62e566540f48f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516124fdacd32ed62913c907fe907bb3b39b92af20f2c08ad6e53bc6463a0aba2eacea25bcd3095645aaeb4a7ee59419bb16d65963eb92ffff865bf6cd1aa09e2275f045a7a431d0cf2b2740a2575b1d7142b04b8fe0b018bc02fa9a7b1c942ad58b93af5e1b5bb4d0c9921eec35d60c1f92bb55e943a4e9e818576c1a89ee4766f0015d448b0301547b4bf465399fb0a783daeb4de4acf2b695be7c931f54c5d4ad3b5ecc6eeeef89e6067541eadbc5e50c410fbd83fd038597140b8d1de79e33270c429fa8b6cd06c3d01370e970a00cacd00a1240ed85a3eafb1de0d6dd75ba3eb897664b9d593ec774afaf742912ca1f254a7e7413dfe88030a6e63d208b5805b1dec8a97613069de0bbe1d9078c637d9cf9312e2f207c53d965d4215d2bbdc1095570a1cb4592c11a77d62e566540f48f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516bf9a20471042d9a5f75dd0439f6778253c5cbd05722a4fb5be0e6a86e8e99a0d61465f37aef530d9981cf65111f75c6e914c65619c022e492b4e38a65bad194f19cdafe87257c89b535fae31de4bbe0c59a8c632cff5184ae2843187b8609597172feb68d19d368f0ecd3dfee2cdc0f6472a321d72057119a30debbf72c348b7e2c20fd287d29fff36c6a0ba759c0fbe5c7395283719c53675f081c161005c4cf99de2372e439861969342fc02437fbad9777db46965233b604c1d0d415eee7b553027c1803c600132c257c42b47a0e363fbb3a58215c25999fc1f1fd819e8d65a6ce22482cc22336ddf0928ad3fedc46efa0bcc0859e1877d09dfe697d48e11cddd6811c6559b3aa3730a4709eb1e9dee2af61dfa38f1d77c3f0027f89b2377e215786999fc404c39823fd1ae69998b19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db161442d95382cd961d4319c6e8f019b1b53c5cbd05722a4fb5be0e6a86e8e99a0d61465f37aef530d9981cf65111f75c6e914c65619c022e492b4e38a65bad194f19cdafe87257c89b535fae31de4bbe0c59a8c632cff5184ae2843187b8609597172feb68d19d368f0ecd3dfee2cdc0f6472a321d72057119a30debbf72c348b7e2c20fd287d29fff36c6a0ba759c0fbe5c7395283719c53675f081c161005c4cf99de2372e439861969342fc02437fbad9777db46965233b604c1d0d415eee7b553027c1803c600132c257c42b47a0e363fbb3a58215c25999fc1f1fd819e8d65a6ce22482cc22336ddf0928ad3fedc46efa0bcc0859e1877d09dfe697d48e11cddd6811c6559b3aa3730a4709eb1e9dee2af61dfa38f1d77c3f0027f89b2377e215786999fc404c39823fd1ae69998b19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16943e115c22a05f3da0a9e31e0d96ec65e0b103915c1ec3921045e0fac339fd6e496842ca7f3ae39b600f43e68bbd1b701db769dca80903ae0c7296141eedf516990399973dde15f6e4f6ec6de40e4a2a65053d637398d061ccd068b55770a6363a9d69f1ba8785fd9afe83d4822b98a15d007112ad0ec81614c5d9161a8cd1fb361e85a8db9304a2e9301990a8935bf34db682c5a35c86902faebd584b6c2c0ec1b8acc260f78a94128a13d107da70689590496100300d891337fc3c8b54db1ebf8ac9ef3fe447791d2972f39385ec188a47fe8ca62f672b59e0c1a6ebd28592a595f6804132f6a4a5f575d4fb05973be5bd52f5583b6f8826cced1fe3d07263fd55debf5a848e9200f71d1306ba2736670c68a47d6207bd99c1f5bc2417410f66ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516bfb721f277edb5110a3136d6715d77cf834db5bf97ab31a42d623b74ff231bb24b6f9429812c231221803a4fa614c2440d4264782c35297bfefe883e408e41ba63b971d0083de3b817b255dd932285e4e78f1b3d4416542a94cdf42396fb299accdc24a6b5131b01936efc0c198f18d11b1f2811f2dffb64a07171ad10e71b007e87adf804a00842f5772cddc20608d1f1cb2c3c1ae0ceadc9132e5d4f8128b30650f2674dda70f57abed806c7890f604bb8abdbc3b3cb5406d5fd66d693393fda3a275e8bbd6528c23a4c3600b789f0fdb0e6ea11ba8945bf516a2288eaa612ee820d26e97375a1f4d1fda20ac6c1181943c7eb0767f20714f94193b133a7ccf3fa31a4a6abdf924eaf6ba3e999810259014acc55d84cc67964867f2bb1a995a334746f3fd13cf79ff9db23ad7b218b61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16bd26b43283fdd0a67474a6b7c3dc5124834db5bf97ab31a42d623b74ff231bb24b6f9429812c231221803a4fa614c2440d4264782c35297bfefe883e408e41ba63b971d0083de3b817b255dd932285e4e78f1b3d4416542a94cdf42396fb299accdc24a6b5131b01936efc0c198f18d11b1f2811f2dffb64a07171ad10e71b007e87adf804a00842f5772cddc20608d1f1cb2c3c1ae0ceadc9132e5d4f8128b30650f2674dda70f57abed806c7890f604bb8abdbc3b3cb5406d5fd66d693393fda3a275e8bbd6528c23a4c3600b789f0fdb0e6ea11ba8945bf516a2288eaa612ee820d26e97375a1f4d1fda20ac6c1181943c7eb0767f20714f94193b133a7ccf3fa31a4a6abdf924eaf6ba3e999810259014acc55d84cc67964867f2bb1a995a334746f3fd13cf79ff9db23ad7b218b61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16bc8be61bba9cbe4747516b0dd3a0650ab0824da00bc3f27fcda8e03342d34664734301f4324716b60c99eef9851060789f2a471e39be34af85949f0464b64f812f8863fff745d840bfe5f262126fb8be6d80772038809fc1ae78e424141e3d8ea337853b26b7eb86da7aa02a3b5ddc5adecb6945359572846f44f7ebac399f902ba09f5bf2be42b2ca7333a588b5464c50185a29c7b26268773bdde59955f98a7820823b1dfdad4e1d2caad8f110754870c7864654fe18e4d96da5fbd59b1742314d0cfc66600f75a711fd403a46b48604db8d629790168f47d7de5796c17f51c92d5087d9d4f3a4ec18892b5c85844ce79b85b9b39a26baa4c98f10fd449be3fd5a0b084a276ca29ad97f52386a61ddee2af61dfa38f1d77c3f0027f89b2377e215786999fc404c39823fd1ae69998b19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db1636acdac09fdd152fa4b76109e469f310a920ef7202beb642a28aa55af0e9354e488226ddc95c321b3d9f3db4c075a5bd878bcfb99cd157aa102f42b4c79c735b9752266d2a138252cce3a79ac9a530e5041c551d9551b0ed67492c479c5ed9c32d1ffa8449c58e5fd4e934bdd999cec59a8e0197acaf821f55fe06c519628aa68b898acbbb9e641d9bcbfe10dca726bf2ac710e03e585e56fdd38e1d3f7c3038f413ae7dc1e69b7ab274eb7e8eabf29dfa2e2df0d14977661c099997b4ac6cd3ea140b370f29008017924d4c17ecd0d69c36aad6c79032e2723cd0693507f9c3e7563c5c0b2223e5a6e7cd57eaffadc35cdc8081372e954aa352314d7b36b108cebc06b32a0023680feaeafc6f127995ca90d66da9e273aa483e9dd1f08bdc69e215786999fc404c39823fd1ae69998b19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16e4cde218a0c5f6b092f877ed54a20a78a920ef7202beb642a28aa55af0e9354e488226ddc95c321b3d9f3db4c075a5bd878bcfb99cd157aa102f42b4c79c735b9752266d2a138252cce3a79ac9a530e5041c551d9551b0ed67492c479c5ed9c32d1ffa8449c58e5fd4e934bdd999cec59a8e0197acaf821f55fe06c519628aa68b898acbbb9e641d9bcbfe10dca726bf2ac710e03e585e56fdd38e1d3f7c3038f413ae7dc1e69b7ab274eb7e8eabf29dfa2e2df0d14977661c099997b4ac6cd3ea140b370f29008017924d4c17ecd0d69c36aad6c79032e2723cd0693507f9c3e7563c5c0b2223e5a6e7cd57eaffadc35cdc8081372e954aa352314d7b36b108cebc06b32a0023680feaeafc6f127995ca90d66da9e273aa483e9dd1f08bdc69e215786999fc404c39823fd1ae69998b19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16674f654ae5de1ca3c3cfbcb4905679aa6e5efc397b7878b147e983fa4ec565a5b36b152aa3647040c206c543bdd4a67f43a8a6334994f7b77428dc72c1881dd6867c828226ac0c1951f6bd74e56269a54640d4145f5ff41c7e5d749fa758bb92e90193c190328fdbf57b5a4c2d09190ed99b94bf7b6cc88938fb5c5ab535413b02d0ea70b92f8febc249ea6d5d40cae0f1a1e69929c6d3630bde63edb4c58bb46c80d6ce3ae0a615b75a955ea678a54ccd6fd74b6cf5bfa027356d009e440462871d8ffd62ff5c017be1e1956c2dfe7b13d44f635886f16ad63518cd2173c1586d0e511d9d3012fd0f7db2bf3c2a2c3ab2452e40ebc7babb03c338a549d4cdb0a8dc8512b63c13e1154918075dcab859df4095e3efd77d7da7399bdce812d61ced4916997c532704e74c8bf77ae7808293741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e9055167ef6f3ae440d58809001c6505332a965350765ae9999fe9e708394a1e2b4e703800d9cf6c655526f4a5db7db50ec9bb4283cd1c0ca27d729f152766ad4dd7260e8d15679ea2829ee4cea189b3604b361e73114811426443a6fb11909b209e52063a67ec3fc7fb274a89561cde8a84e871c4b2c96399d2ee63dbc478880409e9cac4b0549ee851a01d39087798c2ab7f3b6ecdb46564cad6dc1d78e73a284586e20e19689b9dd2ca4c572860c9e412b6aa22ee56dc91bb9cbef443ee32d43c03df981ef2be05da2168a334a60db625cda08c76e3ffd376ebe302a387139b101d6c33f502ba42e95af6a977c40a662fb74819c116d90631d44a3d884c2d315371ba3fc7af41c1d5dfbe11d0caa1203abcd3a417b530b1c45d737f01ae5f6dd5bcf9337aa5a4a8e3505a36e8f28df7d089048f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516e3286a72437d0292e28ad687559f0658350765ae9999fe9e708394a1e2b4e703800d9cf6c655526f4a5db7db50ec9bb4283cd1c0ca27d729f152766ad4dd7260e8d15679ea2829ee4cea189b3604b361e73114811426443a6fb11909b209e52063a67ec3fc7fb274a89561cde8a84e871c4b2c96399d2ee63dbc478880409e9cac4b0549ee851a01d39087798c2ab7f3b6ecdb46564cad6dc1d78e73a284586e20e19689b9dd2ca4c572860c9e412b6aa22ee56dc91bb9cbef443ee32d43c03df981ef2be05da2168a334a60db625cda08c76e3ffd376ebe302a387139b101d6c33f502ba42e95af6a977c40a662fb74819c116d90631d44a3d884c2d315371ba3fc7af41c1d5dfbe11d0caa1203abcd3a417b530b1c45d737f01ae5f6dd5bcf9337aa5a4a8e3505a36e8f28df7d089048f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055163465352a3361fd72ce8297d92e8ff93a5b10c5553812d369d66c107a51199bef221edbc6384f27f793de5ac0d70da1c9630bc768b341723c2499ec5ce7e2d79ab07e1b2aca79e0b65f01200e8e503d9a61f5b804c8b75de404c57cdf519d10865c30699401189de14acc7889d5abfa46eda05c0ad449d25149f5991eebd230833f50acb566ff109801da513ec4623d4990205dff896a65431996437c1fe2aec891b97d943322a92480929d9a0f6ea6153687d085e31d92f66d5c11beb2b04a3216c4a8e81687366eb287501084b5385b680816f5f5ba79f0b4d7054a4253a4833a9840cbce867c525f323ef7b8e1abe0d99c1b22b6976279ea2a9ddc93f286748442492a2863eef22db37f70180237d5670c68a47d6207bd99c1f5bc2417410f66ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516fa609de60834eb1ad670d484122327d1c2c252c30393fb93ccba78c2b51fffca6c70ae5ae606d2445b10c1c896ae4f9ec55d38babc439e4c023bfdfd96673a5f48b829b105ba28d5ba6818516ff026647073b0635d49c61b6ae4bc29fb336a7c82835af5569ff6369533e4286f5e72d56860baaf3521db156b32b2d373329f2b876f2c43e0749529a16ecf8dc766d1856cd500b5b3184c02e3adfb6e5f70cafb5c48b483ac0ab2a13ad4adecaf83c5bd846ca8f883f9e1be5856c9f1df7e7ec920ea073adb1608c74f7700c7844d795f00267090b7d7bb6818f48ae4e7d5e06fa7e13d66655d616651595e82c34a7b9c2204f8cfa7bc5e54250450f4a38f08caa6e91143e7ad178f3b62c580de426dee0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16134747c2b49e50d799fc1485af3f3e383a5178415c76635950c5fd0e84b75aaf6c70ae5ae606d2445b10c1c896ae4f9ec55d38babc439e4c023bfdfd96673a5f48b829b105ba28d5ba6818516ff026647073b0635d49c61b6ae4bc29fb336a7c82835af5569ff6369533e4286f5e72d56860baaf3521db156b32b2d373329f2b876f2c43e0749529a16ecf8dc766d1856cd500b5b3184c02e3adfb6e5f70cafb5c48b483ac0ab2a13ad4adecaf83c5bd846ca8f883f9e1be5856c9f1df7e7ec920ea073adb1608c74f7700c7844d795f00267090b7d7bb6818f48ae4e7d5e06fa7e13d66655d616651595e82c34a7b9c2204f8cfa7bc5e54250450f4a38f08caa6e91143e7ad178f3b62c580de426dee0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16b832b5f2967c71593ea6aac9683385c1db55dcadafa08649179ebf825d3b0a7287293eddc1c9b56b967933c21a47655671ebdb6091d0d074df96c9fcab746f14585516faa70826b99843fe8ecebe16f9025c3d4b220f43db79d407da413cc6b7177a206a76199ace5b8b378ca8672a41d949b996bede9c59d70442bd5ce193a14ceca87b012845e8650da2b2e3461e0ea3f907a73ea4b03b51681b62c304ec741da93e97d9ee838998a2a407f367a6a56f248123a4784dc82b99f3188c677cc0c656a9451fa1fd0b9110a71ee020dbe696475a35fabf9234085b8e3e61dcce169c26d9597189e6fa14e23da5f9c9c5759c5c6578f0b903226e368991d0e98d2de4de7aef65afccf2791939047723c72e0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1637af5a9e478adce98fbdaf678b3a274ee91b8b45de63755cd4ae7e17ad9030f84b0d73fe96a808be5d73f49a0b4f4809213f7c17300a7569da105924b8270f4e14772a19689176abb8583c3deb69b201d8b6811ea1b1efbd851f61c84133e95fc9463f73f60bb5c6133baa084705bc09ce2f55abed9fb844203a4c7ae194378dce3678ccb051c1402740fa03a982efbe7204c39618260dd7475fb1f8da2399531b028a5dd2b5dadb17155df791ac6892b4144c64270012ecfcb20bd45af3d82875ab376b231e3d8327e1a44e0e64d7d3e224da097e58359ffe0c785215fedafe10a8ed694c64b50665ec1613cdacee4fb12bcac7e8c11c6530a97d47fe01820b24f7c6a90973f3234d3f24a5d4098c038917262c82665815b77dc2ac6b520a0aa334746f3fd13cf79ff9db23ad7b218b61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db161def29d7a44bc8daf52cf33f7e6749a7e91b8b45de63755cd4ae7e17ad9030f84b0d73fe96a808be5d73f49a0b4f4809213f7c17300a7569da105924b8270f4e14772a19689176abb8583c3deb69b201d8b6811ea1b1efbd851f61c84133e95fc9463f73f60bb5c6133baa084705bc09ce2f55abed9fb844203a4c7ae194378dce3678ccb051c1402740fa03a982efbe7204c39618260dd7475fb1f8da2399531b028a5dd2b5dadb17155df791ac6892b4144c64270012ecfcb20bd45af3d82875ab376b231e3d8327e1a44e0e64d7d3e224da097e58359ffe0c785215fedafe10a8ed694c64b50665ec1613cdacee4fb12bcac7e8c11c6530a97d47fe01820b24f7c6a90973f3234d3f24a5d4098c038917262c82665815b77dc2ac6b520a0aa334746f3fd13cf79ff9db23ad7b218b61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16c2caa78bf07fb46818e758c226764f489cd767873cb7ef062fca065dbf27e31c1f0ce18f9c6fa3cfc09416ee7c0fbd5263dca335524a4fb476a9c16c3098da42720f861fc1b77db373f699f82be67cdb4d15c642668de00df69a365f5f15b79608b3d270d2ad1d3cbf5169c892e1af2c52a6bc3618eb5ca3903d8d5546d864af6bff7bb31fd3edd3b03a63657bb31d97cd1782fcb049bf5ba16cc325e5bf255cde565448c1e90ad8d4a07750a2e2e71f3f10044ae455b1d6169eb1b11b3450d2341975e7e3f41293bcf2b4cdc71b5f93fbdd91c43897540078006f80213b5f22dcd6fa76548963da8f8de3ff84d1250308bb073e5c2e49e0bfa0324fc87f7535cd7eab2012f6a2538ecad89c4caa0d4a76314f206cb489b8f9d7a8155740ee354a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db166e207177b3506df226cb1d62923efe112b56cbe7549465a654a60caba9ff072efffb9c272c75e29dca88236dacc8b35db4aa6a8fe7d18c7c5dc498938fb0223d15699eb93491d60cfe4fc0136ed0b4738880d131a9856d9dd266801f9c2bf3069a68ff180d9847f91355e82003821eddea9e64479e40389d6d969522a4a157434534e59ce6607f0d25a40e0645af8ace6d99df03bceb04dc1bb528d6ecc3208634df54d23c069bc7bce77a07be2bd9980f2d314c2263b5374066358d47f06531ec0f96f6c176ac9e8c69134d64c149718574195f485d53ad4fcd64b946b05572084a605004ba7617131254239d6f5f1971ba732a832db9e4249e4175e1ca99ebe4de7aef65afccf2791939047723c72e0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16ef56423138c5f2e58dc8582cca99ef322b56cbe7549465a654a60caba9ff072efffb9c272c75e29dca88236dacc8b35db4aa6a8fe7d18c7c5dc498938fb0223d15699eb93491d60cfe4fc0136ed0b4738880d131a9856d9dd266801f9c2bf3069a68ff180d9847f91355e82003821eddea9e64479e40389d6d969522a4a157434534e59ce6607f0d25a40e0645af8ace6d99df03bceb04dc1bb528d6ecc3208634df54d23c069bc7bce77a07be2bd9980f2d314c2263b5374066358d47f06531ec0f96f6c176ac9e8c69134d64c149718574195f485d53ad4fcd64b946b05572084a605004ba7617131254239d6f5f1971ba732a832db9e4249e4175e1ca99ebe4de7aef65afccf2791939047723c72e0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16c6e54f2d9d0a44be068cf6dcf5551fa80823abbef7e8ed29fa3e191d8a71f36007b77c8bbc8453c37fcb1e03a314908dec99e0281dda095d21a516abd1870e824ac7c3693ae63de52cac9c42991c4d7ac05dd460eb36bd9c6098bdc02be1802b4021678105c0b678403be342f681f4a2701efbfd9a2d26c7112660a00cefbe6c908d6aee90cef46a3681b61b31cc89205a29b700cdad738a37312c42e219898c6917af1f447fd418eb1dd9dae446bc5a38c85f1599f3341ae7b0291fcf1d301ec7956ea7091c25af62959996fd9dc475aff7a57efa62bec4fac0c34e7b54eb5c1c8083e6b169f68c8d66af574d55748de4e27dc6470d69815ad3c77ba3e3c9c25d63d8c4feeb5f99a71d1af188372cfe2e383ccfbfc8f5ab51d83298a06b9fb78c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1693e4d607c4be3ba7bf9af710e33531f32b5677af5ad779872cc65e0049c87a98892372dcc4e617dcaa2374e5a5a18b6cd21ab2ea0576de79f41c4e9803e8f675af83ea5392bfe091fd25eb1daf516160a890a602ade1c1c4742d1a16d7d55ac47d33cd15eaf574b592bc8eec5582103472b3bfe95128b7365fa458744f95158f0f091978137b3d8cc49704adbc67d72bffb5c65635696eae4858c5b46c389b348aa7a016ef50ff06f0647bb6339c0c15248f85bd4aefe0cf6c6e9fa94448cc03d55d063685b8aa1196a86df5e111a9e01fae726f59db1e3c88323b3bcf14c0ee079daf37a74e3f1adb35256abdc261749763d3d4ffde388ab82ec7f9f849a83009b1c2b08995d0664444ddecca7af576a95872c5f83946fdec8b0a720bf19ef07377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e90551697972d524d03ecb46d7a0c68969837c54f24650c889ad7b76d47cd3ea50d1e7937e6f5ba8f986f1c2d73b33f1ead60d194c9921a08366373824af1f6737baf12af83ea5392bfe091fd25eb1daf516160a890a602ade1c1c4742d1a16d7d55ac47d33cd15eaf574b592bc8eec5582103472b3bfe95128b7365fa458744f95158f0f091978137b3d8cc49704adbc67d72bffb5c65635696eae4858c5b46c389b348aa7a016ef50ff06f0647bb6339c0c15248f85bd4aefe0cf6c6e9fa94448cc03d55d063685b8aa1196a86df5e111a9e01fae726f59db1e3c88323b3bcf14c0ee079daf37a74e3f1adb35256abdc261749763d3d4ffde388ab82ec7f9f849a83009b1c2b08995d0664444ddecca7af576a95872c5f83946fdec8b0a720bf19ef07377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516e7e6dd6a64c95aa583d4fbbd5a5b6aaa9ada5b4d7a83146cf935178dcedd63a3e1f256afa9a92e7005e7526b2105600b03dbf69b3781d35833811f335f6e29b00698c0866b5fc098c2f788b7e0d2395572e6f1fa35b02ccd36486db62a80082f33f1242e116c0049bdfa71fe941b3f193645d055a3bfa467273a31c8a4d5d38a16866c79e2cb44ec27b8a5ca9f9a992eefdead8904b6ef747a12ec965a0e000b05947c2221ad9ed811cd742b7992c30f2e7c54244a6ec1b5d7295249ca9eb2ae2f9cb8fcbd48eef3b1d1c422b947234b6b353ba5c6ec51b0daf3019eec7579246745f9becec420e14971cfa403619fcbab744fc573742076119ee562d27dc2e0191d5cfc4a972ecb149777191502a02f0457d66caeb4fa6d95631b3c6750616066ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516b3d7219d0725c3209308f5d3f5994ccb6853f4026fba02093f30ee66601166c78359f0ddb54f82ae0bd65c11d64755912998795683aed8f35974e655a3691022fbdbd2460460aaf6ecff01708f80ebf852bf6fc0b8d3e66b437cf03e3b5e4ed98e5c4d89e1882f57c9fa3ca7293bcb9fb8021a0567e22650840b8d33e1e0b0f1ad2597a2fff07eb34b7ce164970fae36a9ce94e3ef3a9002baf808450157671cf8ffae24e58eb561d05337e1fc84d4ab052ac8c2036be9ff9b9705a59748b303b523414896e39e25a1880917a37bee9e3370804f4510bf85e72f03daf12730bbcc9a7b96515a5a444bef4418008ee5fb1f5509fff0f9d48eb765ad50d4a179730a79c37a33d0bdda77396bb8a955fd743a417b530b1c45d737f01ae5f6dd5bcf9337aa5a4a8e3505a36e8f28df7d089048f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516a1a0b1730acca089f29601fcceab6b546853f4026fba02093f30ee66601166c78359f0ddb54f82ae0bd65c11d64755912998795683aed8f35974e655a3691022fbdbd2460460aaf6ecff01708f80ebf852bf6fc0b8d3e66b437cf03e3b5e4ed98e5c4d89e1882f57c9fa3ca7293bcb9fb8021a0567e22650840b8d33e1e0b0f1ad2597a2fff07eb34b7ce164970fae36a9ce94e3ef3a9002baf808450157671cf8ffae24e58eb561d05337e1fc84d4ab052ac8c2036be9ff9b9705a59748b303b523414896e39e25a1880917a37bee9e3370804f4510bf85e72f03daf12730bbcc9a7b96515a5a444bef4418008ee5fb1f5509fff0f9d48eb765ad50d4a179730a79c37a33d0bdda77396bb8a955fd743a417b530b1c45d737f01ae5f6dd5bcf9337aa5a4a8e3505a36e8f28df7d089048f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516056a5dd40392bf7c0997c3449375c4d149834ff8b4caef753f74d5d56e6f71cd8b01e183a0751c17cccfdbafbc02cbdd6e5b8a065d08b4606ff39b23bb7714ab3ad83868f9bfd5777d5a159c8fbca6f533cc735d237229cbc0bbeaf1b3b3a4ba0d7f6f8925d874c79198bdef0c4a6fe7213b107f1f86852341ae720874f7c2414c20d3686567bcacf52ece3e3d5d50cc983e071f124222eec3d363272592a682b70706ad6309bbeca5ff16b735f0316fecf5843c575e30f4174de77b064d10b94f3e625118384a6180cf207f89376e5edfd91eacf1fe1c4578bedec94c6d8795bd79bc35d22bf88f336c1f4d1edd6ec5912f1ad088ad879f1906ce0acf0ef58e49dd49695f3812865b853e9d3ed48993d66963ab2f621bafeadb3aefbdcba741ed4916997c532704e74c8bf77ae7808293741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e9055167b1b2da8569c4922e245ca346fc7bb1fbb65031edf293849d2fec6e9c6cee76ee74f627e6387f1cebacd7fbc2abb4aded3a6854390366c7b2cb586bdc97b5ef1b5ad6394f0d359f7c141f87ef70cc4f5a6753718c53e34a1514b2bc3d83f50cd8a8e7b63b09b8ee2e26919d62a2c193f96eb6024742bfe39774ca2396f255a82e5772361faab0ce7b47c120022d97663ed34b3aabb8f8e879076ccf98af2df0086b026c192e2dd79d7bb744d7bcf30c77dc703ba298fd65e953a97ac5fcdfb765a0adabaa24de6cc8901e341095a8cb091e7ccec280ac30bc968e5e0a50d3967ef5d14ededab6a8146ea14b91bbafbe02c1856529c1ef523ee7ec3b516062672bc1335af3fefbd378401d31384f74fea6233dda03371952e8966a8fc02f424b97377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516b9530d761de5cf3637b41eb72ecf4555bb65031edf293849d2fec6e9c6cee76ee74f627e6387f1cebacd7fbc2abb4aded3a6854390366c7b2cb586bdc97b5ef1b5ad6394f0d359f7c141f87ef70cc4f5a6753718c53e34a1514b2bc3d83f50cd8a8e7b63b09b8ee2e26919d62a2c193f96eb6024742bfe39774ca2396f255a82e5772361faab0ce7b47c120022d97663ed34b3aabb8f8e879076ccf98af2df0086b026c192e2dd79d7bb744d7bcf30c77dc703ba298fd65e953a97ac5fcdfb765a0adabaa24de6cc8901e341095a8cb091e7ccec280ac30bc968e5e0a50d3967ef5d14ededab6a8146ea14b91bbafbe02c1856529c1ef523ee7ec3b516062672bc1335af3fefbd378401d31384f74fea6233dda03371952e8966a8fc02f424b97377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e9055165ac46f661674eddd99b1785512836caa121afdfaf4a02bf043c100ffc016049a300ba0c4467cf2037bf2ecd9f2959463b5dca8f0979f9b03aa94ff63fe20413aa3541adf7e0df35e17809f7aac44cd27c66e9f15051aa464ae098c5243f7d2775294bcbe4df18496273d399803b0dc7639b92d472456c5958253d04fd39fc513ae7975bd188dd3085f89e86be1c12095554f91b7207fca889df294cc8d7ba7fb1ca42069ff73f43f013402eb03ae6ec3fff42bfcfac2c8329f70ca7b373e87be02957ec0a19b64e6379f4aebd03e10ebaf0de254d106aaaa8687fed340fa452c9b4585af340006b115455aa68eebb8fdfaa48f4440ff705d385caf7652c3b5156de3b174b6fbd9d12fe36d3b8c38919c061107b705ee72ca36f0359d4ebe985bb697ad59211b61e6abb9f581d9668feb63da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db1619acd65b124657bf913ce1a5fa8098535ba942a4aa1d8a57825c8dc32a7a5a0cf881f942b3c428166bb43b8b14ed1dca36c553b1991dfeff6460dd2c40e58304688af35b725d1da9cea46e3f3718f6c5eeb683cd5b3a7bd59eed95a869642a532a77344a522761cd7a76158f8dd62f6a997c91700d00bf7795c6823fe8356ef3bf2369ce11e5d36ddc0f448b468a574c37f3ff91c9b79fbb9bfdbd9bd5458b6d76be71b7827dca19dd00fe99e269ea45df780379828886b2d5c9f7ccd43e2f8a9f1d3fa8dd692d3f18b748bc2c45000c332b65d4fd87e64830f760e640a7366654a71230c1dc7fb5a512e1abdcc092f3116fdc20f7e82ab49f2b3a21655afcf500bb351ca61863907333ee14799e69bd3b19700ca383e718bc41a324b79c671320bf4af69d8f8574cae37b7974a54b6b589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516afce40bc75c0bd675bfdb59642d20f0b5ba942a4aa1d8a57825c8dc32a7a5a0cf881f942b3c428166bb43b8b14ed1dca36c553b1991dfeff6460dd2c40e58304688af35b725d1da9cea46e3f3718f6c5eeb683cd5b3a7bd59eed95a869642a532a77344a522761cd7a76158f8dd62f6a997c91700d00bf7795c6823fe8356ef3bf2369ce11e5d36ddc0f448b468a574c37f3ff91c9b79fbb9bfdbd9bd5458b6d76be71b7827dca19dd00fe99e269ea45df780379828886b2d5c9f7ccd43e2f8a9f1d3fa8dd692d3f18b748bc2c45000c332b65d4fd87e64830f760e640a7366654a71230c1dc7fb5a512e1abdcc092f3116fdc20f7e82ab49f2b3a21655afcf500bb351ca61863907333ee14799e69bd3b19700ca383e718bc41a324b79c671320bf4af69d8f8574cae37b7974a54b6b589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e9055169a045950c47cc6bc9aec04d0177ad5c63d090c2c6dcd81df5e89d90d23b03ec7bd5e8cffac3a7ab095d016037c91ce32a2bfb53a1adf18782a8bb66256f4ba5d146e48e9bb6579c164b28699d76fe36757fa280700ebaa769f25d42595efb672ee8494f08d9216fbe6195f89b15c12415ac68ea59c297d7f657d13eb2eca219fc1578dd6937bb399944dff55115674b937f3ff91c9b79fbb9bfdbd9bd5458b6d76be71b7827dca19dd00fe99e269ea45df780379828886b2d5c9f7ccd43e2f8a9f1d3fa8dd692d3f18b748bc2c45000c332b65d4fd87e64830f760e640a7366654a71230c1dc7fb5a512e1abdcc092f3116fdc20f7e82ab49f2b3a21655afcf500bb351ca61863907333ee14799e69bd3b19700ca383e718bc41a324b79c671320bf4af69d8f8574cae37b7974a54b6b589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e90551658d987d01cc1c2688ca386f2fa7d310b2337da0d835a994ac8660c350ac055d9d5e9529adac055e5fed994091e2f0f03bcc1d785c86101b866f070879b01ae5bd625155f610b46988552a03c8439cc6521319d48326c9717122a94eef9fcc4c0826157d2d126fa069b254a8064064cbeb5c50617d18d38900fa75c4b6bdd64743f50acb566ff109801da513ec4623d4990205dff896a65431996437c1fe2aec891b97d943322a92480929d9a0f6ea6153687d085e31d92f66d5c11beb2b04a3216c4a8e81687366eb287501084b5385b680816f5f5ba79f0b4d7054a4253a4833a9840cbce867c525f323ef7b8e1abe0d99c1b22b6976279ea2a9ddc93f286748442492a2863eef22db37f70180237d5670c68a47d6207bd99c1f5bc2417410f66ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516a63c3cb9f506a252a757a662abe65f422337da0d835a994ac8660c350ac055d9d5e9529adac055e5fed994091e2f0f03bcc1d785c86101b866f070879b01ae5bd625155f610b46988552a03c8439cc6521319d48326c9717122a94eef9fcc4c0826157d2d126fa069b254a8064064cbeb5c50617d18d38900fa75c4b6bdd64743f50acb566ff109801da513ec4623d4990205dff896a65431996437c1fe2aec891b97d943322a92480929d9a0f6ea6153687d085e31d92f66d5c11beb2b04a3216c4a8e81687366eb287501084b5385b680816f5f5ba79f0b4d7054a4253a4833a9840cbce867c525f323ef7b8e1abe0d99c1b22b6976279ea2a9ddc93f286748442492a2863eef22db37f70180237d5670c68a47d6207bd99c1f5bc2417410f66ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516046f0a05f3d43d5039f33d5bfdf0d42b283b245b91aca69ddebf5c78a04203118a98156e384ea6b64e9f893159d01f9c4a5cb4533a7ad7bf99130ba69a28e133dd66d0bafa92f399346116b5d6d500eeb5d28350db4532f5a4d9e518694738ad2691f9c87e58e22a08dcf5d6187fe3f60455af801596ec31f351560b7b00a90c72be04a364f04492f0ea085090cfebd7344f48adb01a46ede2e658a07ab3c75c0f4603aa41fac8779519a5c0afe275cf8f040b84d597dcc73f3903ea39441b87149735dd6b55e6238b1a04052e7b87662deaa86e09f8cc9bdefe959640ca99a3ecd69f81434164652057cd5a16eb7823896a84fca60967d3a5215bd8486c5addd946933fd03b59d371b01ea9510af03afcb8efa73bbfdf1f2af4b281d144c32c05c0b6ffac3dd0c9fb9006f4f267863a93741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516a3dbdef3ce829889b33f3f13c1ccf08faad25facfe271a72503da82d56432d4091a10d04a1302ce598bcd937d8917733311e851f544ad90a40c98bd3d84da525e5ebd3f5f9c173a879c3245627ee12b8dc444a714271aabf83fd61209f290fb0ff42f80a007b99465eda8e532e032afdf2a9c543a359a6042823251f036410110420ed001628174ca1d0901c516642b98a73b75619a3a34f4382f753ec3539eca029207079a4a2df1f69eb1d808df0e53296dc6344d4cab78438237e954590ed5d0e137633d166a7c5330e441bd6682bba6b3416c840092484f13064a826ddf420bb5751aff8b6b5f4ab306a27b094525e6540f4de8c785f20b66aa77295f45589ca042274e9f73e811a7d224135b16f6233dda03371952e8966a8fc02f424b97377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516118e23a27574d6caa3ecc52dd0311e57e934f442f81963d03dcebf3a89aa6e2d91a10d04a1302ce598bcd937d8917733311e851f544ad90a40c98bd3d84da525e5ebd3f5f9c173a879c3245627ee12b8dc444a714271aabf83fd61209f290fb0ff42f80a007b99465eda8e532e032afdf2a9c543a359a6042823251f036410110420ed001628174ca1d0901c516642b98a73b75619a3a34f4382f753ec3539eca029207079a4a2df1f69eb1d808df0e53296dc6344d4cab78438237e954590ed5d0e137633d166a7c5330e441bd6682bba6b3416c840092484f13064a826ddf420bb5751aff8b6b5f4ab306a27b094525e6540f4de8c785f20b66aa77295f45589ca042274e9f73e811a7d224135b16f6233dda03371952e8966a8fc02f424b97377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e9055165193b6791f23ea2006dd159d6a8d0969432e3799c7c6b5b19e796c2ae7eaaf72a77fbef1c3d1157c462c5a4ebcb20320e47c8b85025332d3060d3aa53e93ee549d1b420b402bf1ad0480534e9ba7384166d811c90764be01726e7776c1384e9d825033a43c46b49bfdb6d90e5e343b190fc1ff2cbe6b33265f69c191c02b2aef02a49672e9523ce4ed748eb36e9e6aed55201211e282bde64af92a41586b647eaadeee067964ccb57fc507e8f41fb1a05c666acdd0914437891c53354eb5bb8f00ddc191a8802b96e59d72ed72fa5cdeaacc3d4fd1576a21e1a7b419bb231581273ba28f99d5d098656e9ef945e4b35c65c7b5db57970525714fc29c6d555a58b586d019a455b50a1a92dd754d726f99061107b705ee72ca36f0359d4ebe985bb697ad59211b61e6abb9f581d9668feb63da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db1610ad5fea04b80fcc66e6a8941af96398e350c3a95055692305497ef3ba602215ef51d0ff0f4f8ce03f368b9a44547f698883715a5462fb9d2341110dee0a89d62e36c571106874220a01da92c9624bf2ee8cb27d15a4dff9dc6c0bf0cf1eb18c718e0908213e5a5ec213847b8282650fbed8849eec0ca9716fcb2d0b94197c3be520e4366ef6ec60d81cdca0b7a43001cbd8342423be9c0496709f8510006fe33151b60d137daff8c8d53672415724738a14ed66827cdc66e1bda4079ef6e46ae642cd399bae2008f483bb51beb51532acabe671d2d7a06cd1d7c0d14cb013002d1928995389872d068482ac0c14f333c99e345bed31dc5d261d07084d898a55b9bbc15d04223e561065be08c8ce399c406a551269b5f48a7c14269fb1008fe182cb55102e48c8595506b72cb557f41a19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db1621a4ec98975edb541458c367da86bfebe350c3a95055692305497ef3ba602215ef51d0ff0f4f8ce03f368b9a44547f698883715a5462fb9d2341110dee0a89d62e36c571106874220a01da92c9624bf2ee8cb27d15a4dff9dc6c0bf0cf1eb18c718e0908213e5a5ec213847b8282650fbed8849eec0ca9716fcb2d0b94197c3be520e4366ef6ec60d81cdca0b7a43001cbd8342423be9c0496709f8510006fe33151b60d137daff8c8d53672415724738a14ed66827cdc66e1bda4079ef6e46ae642cd399bae2008f483bb51beb51532acabe671d2d7a06cd1d7c0d14cb013002d1928995389872d068482ac0c14f333c99e345bed31dc5d261d07084d898a55b9bbc15d04223e561065be08c8ce399c406a551269b5f48a7c14269fb1008fe182cb55102e48c8595506b72cb557f41a19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16af6dda5fc929e86d71e416c85332226859376f815a6e0188cb85504879dfca17096d98654b10cba82d8e4397f43fafa385e4b2632f27ceaeb9e7bd2f8f6181391d9ac90b5c6f13e1d94f40a79f7f66ccfa62b28f5d9e8e776fb3f115bc6e755a665460a39b60f4698c4d60c0753fc493f346dd991094543f83594efeacc99e8a4e1b74b6b13662e0e24479fee1db533fe5128990d62c26a4b9843aa548dde53bc910445bb2cc420a60e7b44a51b9915403edda07ecd08c1a965034c03b74f98afe15a63e9d96bde9cd89f76fe545e3a3174b0c94b20a6cc77a2cedbedef3b86fb1885ae25c1a2b74c9047d52bf766a17d42a5d76b0bb270281c43ad71583e122dff7716c6782449f85e964e8d8a1c360984002122a3e13164440259c690fabc0b20423d2d991f37aa6264ec56a5b6c4f61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16ec311014e7096e4d8f1cd9fe10a9c3bab00e6007836018d7ddeae2be403bc6023afbd757ff079ef893e158fb0b532f618176b29ebb72fb0d5b7afcaceebc2662c01d1a75cf51d8f65b98ea5a2fee921cef90bfb14024d9af3f63a45a0241a7f2039d336811c5c04e4631df6e9be04875c74ab72b73e844a282dba415225b95f25bd6b362f3e6a8a9a54bb74e9c9d1b23e92f56271a4bf9420363237f9eb0a77fb96f5c523d0bfadef3506730a859e481e736c5814c1d6cd07e867d389903aa8a56273ed50a1a809cbcccd28761fa0befc4898537851c00fb8e177b0baef028f9e74f3c7b75281d3c66c482e1ec3cb62cc96bead246343ffcb7116d0c1e1417942b18092b6a6b7c52a14ed7322e4d629059014acc55d84cc67964867f2bb1a995a334746f3fd13cf79ff9db23ad7b218b61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16e68a2051b4e597cd700285a2c4efed708ed602f54fc3917bd90118094f3bac1b3afbd757ff079ef893e158fb0b532f618176b29ebb72fb0d5b7afcaceebc2662c01d1a75cf51d8f65b98ea5a2fee921cef90bfb14024d9af3f63a45a0241a7f2039d336811c5c04e4631df6e9be04875c74ab72b73e844a282dba415225b95f25bd6b362f3e6a8a9a54bb74e9c9d1b23e92f56271a4bf9420363237f9eb0a77fb96f5c523d0bfadef3506730a859e481e736c5814c1d6cd07e867d389903aa8a56273ed50a1a809cbcccd28761fa0befc4898537851c00fb8e177b0baef028f9e74f3c7b75281d3c66c482e1ec3cb62cc96bead246343ffcb7116d0c1e1417942b18092b6a6b7c52a14ed7322e4d629059014acc55d84cc67964867f2bb1a995a334746f3fd13cf79ff9db23ad7b218b61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1652bad6c29435e7ef6a51f6aec7c84cea2e4d6e47e480ba008bbe12d6a19b8090285e881e01ee291fe92c77b667d68b1a85bad31d81603e8cd6977d3788bb535118211cbb20b6f4ab49838210cbe8cb534f9df591ca1966e8d43c8cfdb75230a9f8745aac79df80f963dd435180139a22c517f7b91d914f6d3f983a56b0d42728441f0d0b6b91998178a588d96e43de12806844afabb412cbf65bc0b6c900925ec99392a43d7ed19b8857fc21bcde03d830cd3e51e534f49c1791b000ddd3a6779ccda3a733c552488105d38dcdbea8798ff2ee9c138e6c13524bc1a1ed9a4a92134acaa4561052a2e073518237a5b53d65c7b5db57970525714fc29c6d555a58b586d019a455b50a1a92dd754d726f99061107b705ee72ca36f0359d4ebe985bb697ad59211b61e6abb9f581d9668feb63da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db163e7cad8222e894ac78f1aec2b1276866227c6517e8dfc7baa25d12763321bce8f2f7afe6b1c965b44c2a5c540dae2069d75e92c3168cef398acb76378d64026fc387cba184f39258dd625adf84cea0bcbbfe9a90b9abd78aaac802fcd8d725e85da3522f6c3b335b102e0bce7e1e94a4de88d6858e5a40ec510269e7b4c084f6f2c04ed1b3d23ea29066c4341c2012f837c6045406aeac3aabbd3beb17bf238e3706793099ebdba5d6c78ec80758f00d7e55893d9a0b2f303f08fb49c3909b4e6efc30ad0df26c0cffca72cf8842993a0fbee56b4e2eedd1974f676c873b5ac56091c9524b2ab7035a69f3cabe59386b449fae1ad1928138c48c0d4f3b43ceab7d6ff98184b74dfb108312c63815214ad66963ab2f621bafeadb3aefbdcba741ed4916997c532704e74c8bf77ae7808293741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e90551641b4f101492c1d10d084fedaa15452d6227c6517e8dfc7baa25d12763321bce8f2f7afe6b1c965b44c2a5c540dae2069d75e92c3168cef398acb76378d64026fc387cba184f39258dd625adf84cea0bcbbfe9a90b9abd78aaac802fcd8d725e85da3522f6c3b335b102e0bce7e1e94a4de88d6858e5a40ec510269e7b4c084f6f2c04ed1b3d23ea29066c4341c2012f837c6045406aeac3aabbd3beb17bf238e3706793099ebdba5d6c78ec80758f00d7e55893d9a0b2f303f08fb49c3909b4e6efc30ad0df26c0cffca72cf8842993a0fbee56b4e2eedd1974f676c873b5ac56091c9524b2ab7035a69f3cabe59386b449fae1ad1928138c48c0d4f3b43ceab7d6ff98184b74dfb108312c63815214ad66963ab2f621bafeadb3aefbdcba741ed4916997c532704e74c8bf77ae7808293741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e9055161541cda6c264b0d8426703cc8a1ad1f0bdba75892a6707daf3ccc13f37e38326605031d25210ed2c6b2e32621943558c0a1906625494ba3f9b250a7297a0bf50b3178e9a23765fd8e9f2c31fccd1f03e0bd0df56878cbca4a8b28c1d354588fed65002325c8ecd064722ebdf1c671d5f420f25ca6b71f481af42e0a512c028432a289486665b35e61d038faf9fcdbc002de80f5f771c1e4e48d09407788548a15ef5d612c767ed3b966884ad9e92097cc2900d27b97f0852ab7302c1eae43232c1c5c4963e9be1ad4823665e27bc66ef8100d8363312e275615cdb04812ef9727aae26f2bf56eb5f8ed95558008eb1aa4ab30d775212307e053ac5dafdbff5b97d6ff98184b74dfb108312c63815214ad66963ab2f621bafeadb3aefbdcba741ed4916997c532704e74c8bf77ae7808293741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516fcd80cd1dea7f2a2e075f16145df22c96df0ffe5ca99510c6b31ebfab745bd585601f5f3f8fbf206e4193dd61a0b0a1322a04ed084e3f8fb7bab2d2b56d929329edfd6e5d8236b2c8e919bdee7a284af1c35309582b66f1a5e8ffccc43b5eabf0fd955e9688f95de6960d089704238aa1bdde81b79ffe2868618b4bd678589eb5380a370d7271ff343b4acab883c6b31984acefc4e424c3bcee6eb2f16e24445f33c2e5ec2b02097723cc0429ee5a3dcc2aa838d017f70d4a4634b51c2ed4595464bbd68b374e7f3312c5e533a9679e957ade5c25fd8d8b7044b17a331c023e08ce651114e9082305555941585d958eb260b8fb61b4f18455fcc2b9c68c67d865e5f074687ff7c1881a8ce38980a90f6b720f9d6e6feda85150dcf8a53bb1bd7418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516ddaa135171b5f19d554d7a78bcd48b226df0ffe5ca99510c6b31ebfab745bd585601f5f3f8fbf206e4193dd61a0b0a1322a04ed084e3f8fb7bab2d2b56d929329edfd6e5d8236b2c8e919bdee7a284af1c35309582b66f1a5e8ffccc43b5eabf0fd955e9688f95de6960d089704238aa1bdde81b79ffe2868618b4bd678589eb5380a370d7271ff343b4acab883c6b31984acefc4e424c3bcee6eb2f16e24445f33c2e5ec2b02097723cc0429ee5a3dcc2aa838d017f70d4a4634b51c2ed4595464bbd68b374e7f3312c5e533a9679e957ade5c25fd8d8b7044b17a331c023e08ce651114e9082305555941585d958eb260b8fb61b4f18455fcc2b9c68c67d865e5f074687ff7c1881a8ce38980a90f6b720f9d6e6feda85150dcf8a53bb1bd7418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516b108450069fddd8af2db2e1065332c3e267318d4d775e51e0e8eb0b3d2524a752623e6863906094ab13f9f8773822390262b600817bf54fab5eef828967156a91d8f0274d64307e64d94fee5c97f83dfa0cfe69429c38e070a6a07d00e7bcd51bb46b3511a49c5ba567ff265e55538005e49766791eda8c9a1881fd9e71b72768b05007cc14dce105d6dfb7547b453367cd9fbebd48c2f7840835bf84d98af33be98075ab5256d0bf2197a6bf95046efb84eccd797b3899cd55a016da331a7cce2a6bdd8e5ff9ea54af061e6e35f011b9fcca8d6a175a84df608b4a8b31e364a97a904d60367a50fe6f36615007468ca9dbcf11520b3876dd2bf936768a04e5cd52c246e5567e6428588414d0839cacfbcbce1a7488bf8bc6a1c9b4ddc020567418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e90551612f422720e5a59bc13b2f8af6ccd38576384cda5334df4493c167d570949f025579f84e49c2777b531fb569b55748f333a033743f6ad88e204b280e8e8fccb6978c83bcd6995a2bc3693af3f9eb0eae60231d5d30db2f86963b03a2215c6adcdcd8b3f0cdd7067e6ede29bee9ef41ed29517283e73de934492107120a1819b90f7315b73c2e6b3abcad4b5ee9cb28af1e0e1b1950f6be0781a555d5f2f9ba917e9552bc80096af665756947bc7637b37e6943dbc8a51a796d21c64c32e5833dc1df2d5b212439853578c0902e06e23c51a18461d65dae377d38815639999c8fa91eff7f080e9661cdf7bccabd535d547ab744fc573742076119ee562d27dc2e0191d5cfc4a972ecb149777191502a02f0457d66caeb4fa6d95631b3c6750616066ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516720539478661c6b14b6a02e12eed9a8483fc7fde72bb29534f9b5a1bdcf9fcbd579f84e49c2777b531fb569b55748f333a033743f6ad88e204b280e8e8fccb6978c83bcd6995a2bc3693af3f9eb0eae60231d5d30db2f86963b03a2215c6adcdcd8b3f0cdd7067e6ede29bee9ef41ed29517283e73de934492107120a1819b90f7315b73c2e6b3abcad4b5ee9cb28af1e0e1b1950f6be0781a555d5f2f9ba917e9552bc80096af665756947bc7637b37e6943dbc8a51a796d21c64c32e5833dc1df2d5b212439853578c0902e06e23c51a18461d65dae377d38815639999c8fa91eff7f080e9661cdf7bccabd535d547ab744fc573742076119ee562d27dc2e0191d5cfc4a972ecb149777191502a02f0457d66caeb4fa6d95631b3c6750616066ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055164951535287586534e6dd11f2dc3f11c99e1ff307cbabad223eaf8f94656b5c961077122a63a42999388ab7ed07f469be51d02e969e303025abf6195f91db32798f6fb57eccbe33d2fe32b74dc5cd16aef713121b59029fc1001a70f7ff834063098cddf518ed356e711995ae8bd44eb2f232092fa47a4b024d8ab0ee6791f403ae7a2d55b25f09e58d5a4b7d5f9cd1c824bfcc03fb94d3db8f01d18ca0177f1ac4ac7dbcf2ff36a4908dff4785b5dbed61d0d12afa01b0b52d51cb3f233ed58dd85683a0a0463746c9c55ad581ee8240a713173b70419e056bc514bcb38ff9b32716a2ced53fb34f0763859cc5c3e5c1070f4981eb3645ba51dc4cf939e770a5a6e91143e7ad178f3b62c580de426dee0956d95ae1aec9fbedac75639303278a8c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1627d2e64d1bb4b1a4898314b7dd2660f1ed55e096edcaff19425f3d99d4383d499fe9ab3dff299b7058b763947838ce68dcbba6bcc103110a919c08f3af558e3f373572b2f1d7981c86ac590b347d483484eadcdb9ce2cb65c4a0f11c25537502bd8f4912fde0fe24a9269099b4a235e15c6973e910fb94925898bdc1ca9c005205212db78ddc10e542671ac2a7548239748abbfe79ab603f3b56a277b2b04fbb0658dcfe5249f62637cc4f7d15661ba53519a3f12b1df7e913ce8f09b1547ea80c0f77bc0becb25b21847f6a7d9f2831c3137b51aced2afca5e04a709b40c4aaeb897664b9d593ec774afaf742912ca1f254a7e7413dfe88030a6e63d208b5805b1dec8a97613069de0bbe1d9078c637d9cf9312e2f207c53d965d4215d2bbdc1095570a1cb4592c11a77d62e566540f48f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516b30cba9fe9a9148b19d3adcbb8da5c80ed55e096edcaff19425f3d99d4383d499fe9ab3dff299b7058b763947838ce68dcbba6bcc103110a919c08f3af558e3f373572b2f1d7981c86ac590b347d483484eadcdb9ce2cb65c4a0f11c25537502bd8f4912fde0fe24a9269099b4a235e15c6973e910fb94925898bdc1ca9c005205212db78ddc10e542671ac2a7548239748abbfe79ab603f3b56a277b2b04fbb0658dcfe5249f62637cc4f7d15661ba53519a3f12b1df7e913ce8f09b1547ea80c0f77bc0becb25b21847f6a7d9f2831c3137b51aced2afca5e04a709b40c4aaeb897664b9d593ec774afaf742912ca1f254a7e7413dfe88030a6e63d208b5805b1dec8a97613069de0bbe1d9078c637d9cf9312e2f207c53d965d4215d2bbdc1095570a1cb4592c11a77d62e566540f48f0bff067323798b5681cf3e2d587e59a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055163f546e0210a5c2631c88fcfe1aea33f037109042163f12b897126f40e8d1bbe6648d119474575e7c8d79604e904b49eb03b4207796dda3c4408649898ab87b46e89144cb54871325ae83ddb8ca02ddff423d226efe88bc982df9f2072521e0100fac9e1e20553ef3720dbf8adb843f8c5f34e8d74f2ae3e3e3d6e78ba0dd7a3980ede814924a9b096e6908296fab795cbfed3129a65bead3f5fb6d8bd94520aabd9e2260ae633f4e264d336eea445c599f1f24bf1c311f51dfcacfd7496230bdde0f4cec066e86f3af9847b441c2eacd3af86fc37bcffb5dc1c58f01b4402a1db5df00458050fa53854a1b9455024040faa48f4440ff705d385caf7652c3b5156de3b174b6fbd9d12fe36d3b8c38919c061107b705ee72ca36f0359d4ebe985bb697ad59211b61e6abb9f581d9668feb63da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db1654bb776a591f6132a44681041a65bd2945e741339a8ea252e5666dbe29fb602c49c500e148f4f99134ae8337aa3129c4c4ac716de239a4ff3deaf10f89b39ea173cea706dc8f3fc13c3df49db58eb25ac93823918da207d201ace2c8e4a4669afee3f350fe171a4e855b805960b75daec5515083fba63a95079e24ccc012d72dfab64e3896e950fef917428c1d1279b968781df15b76488595e849d1e540a8ad790bc0acfd594fb09b95114bbfc523703a25227fd4fcaaad35b3ee51b1660696f78cce1a484f25b8bee35e52f2ebb850096a64d49c905c887972bc902d6f7d0676ddb4218e79b8a08ba2d566a239b1bf26cb6a56330106925140ad632c67b3f5bc1335af3fefbd378401d31384f74fea6233dda03371952e8966a8fc02f424b97377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516bad23caddf07acad9559c65ed18b2a9d9e4ab48288bd83c6f7a16b0785f882a3d29688bddcd84517c9c20978f97508d2c4ac716de239a4ff3deaf10f89b39ea173cea706dc8f3fc13c3df49db58eb25ac93823918da207d201ace2c8e4a4669afee3f350fe171a4e855b805960b75daec5515083fba63a95079e24ccc012d72dfab64e3896e950fef917428c1d1279b968781df15b76488595e849d1e540a8ad790bc0acfd594fb09b95114bbfc523703a25227fd4fcaaad35b3ee51b1660696f78cce1a484f25b8bee35e52f2ebb850096a64d49c905c887972bc902d6f7d0676ddb4218e79b8a08ba2d566a239b1bf26cb6a56330106925140ad632c67b3f5bc1335af3fefbd378401d31384f74fea6233dda03371952e8966a8fc02f424b97377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e90551660371468b71531d368bdfebca85c4d7572f3d5b0768facc93384aa9418ee21aa48274d988863f1182740fa3bb22d3bb9fe18571a1b7c2f481aae22c81fda4785caad1f14117cac6a91c2ad1b883d046973279a33fb67bb4589667245da8742ac9b0d86c0e0357e45fd011ed407388a5ed5fe1a58ab8ae882e289a422d4d1c05c5163590667c573baf3c8245082e1717e89109731375138f360c35a4f88d2298545e61774290715f5f167c65e1729290daca6d0932197ebbf217a4776c6e296c1b2a68997c0165e913d3429feb12b164c29c5d86665783bf60237f00f0369aa674b02143038f7da9bde1cc6ec22f0e35da4eba4e305ec1d7163f71759afcae1a0fd55debf5a848e9200f71d1306ba2736670c68a47d6207bd99c1f5bc2417410f66ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055163cd899fe3fac14af3615bc106b9c37bdb9628605be554937e2691fd75b932b2f96cadeb0f7364cf1cb1308d2786fbda2fb903c56ad06b560a041447633360e7b0e2d757925a1ee317532105e6b38d3345b43f44acc676d6e8cc4011da29d86db7dc0a2aef1af1c0eb818e4ec367be7d78d96d72f678ec694640cee5f7cc2b65d26643380d791dfc4921e39245bf966b29ee10aa13a8d9f4dae4a7677fd0f4b4d6f3415cfffa97331fb9c9a1c259886ce56b58d36f5b5ef439e69f9708e4664945c1dd91d5d2ad1f2cbacf703061f6fb5c3b2aed50b6149ad04566b5e6e0ad9b9a595f6804132f6a4a5f575d4fb05973be5bd52f5583b6f8826cced1fe3d07263fd55debf5a848e9200f71d1306ba2736670c68a47d6207bd99c1f5bc2417410f66ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e9055165c6265444a0590095587348bf292c3e3b0a8877e6ae8543576d437ba2f891462cce58130ba92e00a6dd37e239c57cc611725b8ea534133e52039e5fae92ecf440e2d757925a1ee317532105e6b38d3345b43f44acc676d6e8cc4011da29d86db7dc0a2aef1af1c0eb818e4ec367be7d78d96d72f678ec694640cee5f7cc2b65d26643380d791dfc4921e39245bf966b29ee10aa13a8d9f4dae4a7677fd0f4b4d6f3415cfffa97331fb9c9a1c259886ce56b58d36f5b5ef439e69f9708e4664945c1dd91d5d2ad1f2cbacf703061f6fb5c3b2aed50b6149ad04566b5e6e0ad9b9a595f6804132f6a4a5f575d4fb05973be5bd52f5583b6f8826cced1fe3d07263fd55debf5a848e9200f71d1306ba2736670c68a47d6207bd99c1f5bc2417410f66ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516b0146aa0f7571cb4413cfcdb8e75e5d663c7b4d18af23e0b538863c925bdb35f0ece6b9d3a408b389b73ec555033130be3765f35e054ed31c97d3c812f7f49c5b76310a65171d3cb63f1a8b2754a1e22084d7859d3e7938480db802468afc7b9a75b2d05d0ee421412d1ebc8419f4218bddb7b88ed93a83fff685f1f745311742df35a5f52e75bc79293bdd289f652049a7fed1dc150a00b8a02e8389bbfffa0991fcdb3236e878b673f685273184f0ca516a9e41f4617d6a9d18887dee1e47e41c40a43c60356f87812266cf7dbf93ae18388b4b679c11103d78458f2609020b1225b31c4f63651c6388eb3f19c25d8efff90b840c33b1e68a6a5c8f8332344bc4a2c1d72b56fdf65afa74de0e060d6375098133659c8376ee93aa1330eb4d34a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16535621cc03034d605ea9ed60b1bbd7d3d852e6066198b4425fe0a44577d2483ed324991f1e9944651c2d5314ebd37f8cbb58dad64e65cf98ebd667ebf74148beadabb3f5d65bacb77c9e7b2bec43251acd3430bca1e22826c9072dc1add669f2ebeda3d0515ac580914a9bdf9cef5bcd54633605834fff07760797ec3518af808b898acbbb9e641d9bcbfe10dca726bf2ac710e03e585e56fdd38e1d3f7c3038f413ae7dc1e69b7ab274eb7e8eabf29dfa2e2df0d14977661c099997b4ac6cd3ea140b370f29008017924d4c17ecd0d69c36aad6c79032e2723cd0693507f9c3e7563c5c0b2223e5a6e7cd57eaffadc35cdc8081372e954aa352314d7b36b108cebc06b32a0023680feaeafc6f127995ca90d66da9e273aa483e9dd1f08bdc69e215786999fc404c39823fd1ae69998b19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16ad6f41451610a72fa9a0a8e7b4c4dccfeeeeb7e9372de3e448d8910f3876666dceaffd2a8bcb2133a9f5d0e86df01aedbb58dad64e65cf98ebd667ebf74148beadabb3f5d65bacb77c9e7b2bec43251acd3430bca1e22826c9072dc1add669f2ebeda3d0515ac580914a9bdf9cef5bcd54633605834fff07760797ec3518af808b898acbbb9e641d9bcbfe10dca726bf2ac710e03e585e56fdd38e1d3f7c3038f413ae7dc1e69b7ab274eb7e8eabf29dfa2e2df0d14977661c099997b4ac6cd3ea140b370f29008017924d4c17ecd0d69c36aad6c79032e2723cd0693507f9c3e7563c5c0b2223e5a6e7cd57eaffadc35cdc8081372e954aa352314d7b36b108cebc06b32a0023680feaeafc6f127995ca90d66da9e273aa483e9dd1f08bdc69e215786999fc404c39823fd1ae69998b19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16bcc70454e264eadd96a49e39ec59769f6c11e9171a6c5a5d7ef8789f32bbf00983998efea46c6ae6f80cf986ccae25e0af8dea304370880176b500a365f5059298626795b0d486b03e3cbebcaa1d6b0c1c6ccd5800fba2411eae24850e2ae3f2eb717d72dca9caae65d41091dae0b5adddcb7b88d1bd8abce80ae36ad43f07e66c56b1940f39d6bdb81e3612b956a8afc0d9a0dbabd3b4a4c8375254446a96d89b92f828a2d3467ab098ab83d41a905b08964b236a8231dcd464e67a5773a79fdd3f292900f7004a99eb80d04cd4f9bc734532802d42841ef2cbe3fb81713ff616fc3ee05b5622b35925bc3e1602e0135fc81f9376bff867e1ed7d4c765a2339f1f3ff19d49e56b0039cca0d3dc79f8c823ff41593ae6d7f892140acfd46875a18e073733dc9d2e6f1b4c473add199deda630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16c88ac281cd25fc3ccb308138306936e8ae93242968a4bb562627052e2ab4ec4faacbb93ee29537d0df76a7b2c520211abc6d87a8ddad4180012b85af27345a58486c49bfe69e6fc6a722531a9c3ec90a2c565692fc861e3936aeeb26997c91748810dd6bc3d469be880020b7db89ee68467494d74ff997c38f3a17482bd2c6db1de6977b8c1db157c5fa00a4c74e06054e1a578ca82fbe5e11e8888a0ea3aac06358a63346d791797055e5093d6df97b6b68fa3d5e19ec3c7154dee9096bdccf9347deb43b81bd4ba4d808d165460dcd674460f5c8d6341adfe03a1a7b0f9cbcc05b5b6fbea1c34bebda38adca298f60960ead9302c13f2ee214c1ebb501678b20f18c170b92c19a210ff4cd997a7763a95872c5f83946fdec8b0a720bf19ef07377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516511e5a8df2fcd9a1d6e2bb78e8fd6f5750ed2b3ec447e7b76bd313f8b3e44d94ca776c59d78ac8b8b3eae4643ab39a8dbc6d87a8ddad4180012b85af27345a58486c49bfe69e6fc6a722531a9c3ec90a2c565692fc861e3936aeeb26997c91748810dd6bc3d469be880020b7db89ee68467494d74ff997c38f3a17482bd2c6db1de6977b8c1db157c5fa00a4c74e06054e1a578ca82fbe5e11e8888a0ea3aac06358a63346d791797055e5093d6df97b6b68fa3d5e19ec3c7154dee9096bdccf9347deb43b81bd4ba4d808d165460dcd674460f5c8d6341adfe03a1a7b0f9cbcc05b5b6fbea1c34bebda38adca298f60960ead9302c13f2ee214c1ebb501678b20f18c170b92c19a210ff4cd997a7763a95872c5f83946fdec8b0a720bf19ef07377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e9055168ebab9cadae6cb7356f86b7eeb9c506652cfff588e5bc48130ceee46ce11e4e16ec2b028a2b3d6c0e09fd058805c729185289cd31720ab9b2affe263e31a6a8b702bbcef98f0c95650d90ea203f7e15753eaefc1ae88fef8ecf883b21c3800f12d8f3fe5bc9fa2be914b3576889f2ac323194f344afe56e2694351cce98fcede58c192d78b56d636d66eab58d97e6ad4436f0d4c588720428e37f8cfe4366be5fe54869a0cd61aac607c8b2e7ff2c1139ca4bf551094f5a113f2e391466f4d2c3bc0e09abb46d9877e8b404446c0984ffbd18e55294fb62d7f9da1e3451bd7ba1c8083e6b169f68c8d66af574d55748de4e27dc6470d69815ad3c77ba3e3c9c25d63d8c4feeb5f99a71d1af188372cfe2e383ccfbfc8f5ab51d83298a06b9fb78c8786729d0cbef3512493bf8085ede4da630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16bea5b22c92421d46f13e6e7bc45d80916a432245f09ceb619355a1bf4b9249a26faab42b2f174125663c6917c5df9c5a9b2531e4de3217c1dd7401c757d3d606fc6dc5c6b5d71084638b4cd4a2faffdb1a21b5e54b348f935c37254894d16501d262e6125d23aa188c2b32c6ee6aa8430aa89ba0e5988269d91b71848e8ba176901916487c6e55fa3264c7117f569eb8d85ec32d57f142b261c21a77697cc2509944c1de54e014c2982533caa6001862017eb2b0a17580074aabd6fa29a4865fe7f53e8ad7c6ae67aadb244a164e06b6fa02a68e1b2c825515aeb10b341f92eb2210e1b3661637a9198dadc9f630c435093c5336fc6d5855b207d9656f7d1bd3c5a624ccef5adebe6bcc65c13faed725375098133659c8376ee93aa1330eb4d34a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16ce0b64ff5059f4b1fc028ccaa717de4622ccb2fae657b111d823f09e1a6ce33f6faab42b2f174125663c6917c5df9c5a9b2531e4de3217c1dd7401c757d3d606fc6dc5c6b5d71084638b4cd4a2faffdb1a21b5e54b348f935c37254894d16501d262e6125d23aa188c2b32c6ee6aa8430aa89ba0e5988269d91b71848e8ba176901916487c6e55fa3264c7117f569eb8d85ec32d57f142b261c21a77697cc2509944c1de54e014c2982533caa6001862017eb2b0a17580074aabd6fa29a4865fe7f53e8ad7c6ae67aadb244a164e06b6fa02a68e1b2c825515aeb10b341f92eb2210e1b3661637a9198dadc9f630c435093c5336fc6d5855b207d9656f7d1bd3c5a624ccef5adebe6bcc65c13faed725375098133659c8376ee93aa1330eb4d34a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db167c5f50f440a65bf70b6085d056864b1b8b3c0b980be4702957f8d84c64e754243a16126501222df0382e4bbbad40bb0378fcd9d5cfc888594b0684e31a9d3a9e82911d42fa3157550fab94332fef173d92a4c1e96a1327ce3a22bcb7b8ef7b3d3876b5201a82ecf017829c2cfff30ce9e981ed46ca143de9492d41cc0d9587ac03132b23b947e13113d33c8c09ea30d455e6d1d73150ca2dac4cfab36c5aa682b17c49bcfc3c90e8ee2392ff8f0f4f921803e839b039aa5a7d1305105ee516a7b8e94299394d10e37c05066bbcf75a7e9073e62b07e8428ec41e7bda5424d3339f757af9511561f66a9e8f2c42ce17661f634807c1126f1de52ba28f75e16d7a22dc0818ac6d3b2f523c764abcc158cd76314f206cb489b8f9d7a8155740ee354a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db164718e180c53cbf502bde8c7f7fb3f7a3677dee3bc1fb571c9916874fa14d8b76d092dfec5a5dd90ed3b42216272e04139c96705d2587617c0094f4e3c57a132ccca4448aae3e3a4844afcf57e8fb9214335811977da492322b401f6a56043ad420dc56c5dbd237eff5beda8c73f0b6a0326976e416d5a996df8c6005950818e3950b2d927f91c3c2e07f0aff632bb4aa288760e1b4b8644e76639aab263bf22ccc433458cf3ca907e3d14aa0c90465886ccd56433f0661e2b1632f1558c39477eb0a4523c38206d20a85a56db97446371fa4945792fb127cf98a21c2675a9b254d0cf42ca441588c158adb7a921aab6ee1d798d29f5253b62399aa098dd7acb831b1bb7515cd88beeef88b576560fffa406a551269b5f48a7c14269fb1008fe182cb55102e48c8595506b72cb557f41a19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16a0372ec55fc11ef087ccc16460f1069d532b29689dd11b48959c3e6833569ef16b94227110bba5de7df6a15c68f5c35ff69e13b617d140f8e91829c15a326109cca4448aae3e3a4844afcf57e8fb9214335811977da492322b401f6a56043ad420dc56c5dbd237eff5beda8c73f0b6a0326976e416d5a996df8c6005950818e3950b2d927f91c3c2e07f0aff632bb4aa288760e1b4b8644e76639aab263bf22ccc433458cf3ca907e3d14aa0c90465886ccd56433f0661e2b1632f1558c39477eb0a4523c38206d20a85a56db97446371fa4945792fb127cf98a21c2675a9b254d0cf42ca441588c158adb7a921aab6ee1d798d29f5253b62399aa098dd7acb831b1bb7515cd88beeef88b576560fffa406a551269b5f48a7c14269fb1008fe182cb55102e48c8595506b72cb557f41a19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db1699552e98a1b12691f2d268ae05d8ecd419512ebe06392962ca9054e1b4557aa0c98d6d521839b810951630ed5d295a4e3d98b987f9307d9c72c49d0f5f5e5a106a7e9a4751d387d1237668305c7c2201f9ae4da70c5107e8b8bb50628c27c48b991a9ac3a120210e2dca4a625a0e59d5c24c9a155ed9546f4244ff82b1ca0b292da82e025c7f4d50d3e207e1a3dda61397668738e8c0b0b2dd725b5dc412035b92c49865831134b0b05e7f4b497a5f304cde4bd827fb04f6b4551d697889fda5ff2ba525bba9d136dc2ec12fd5fef5131156888735d4687f5bf4aa51cb91a34ad35d8453d103fdbb812a90b59cc0478a6e8222768b9b5157d175df69379eb9c409b1c2b08995d0664444ddecca7af576a95872c5f83946fdec8b0a720bf19ef07377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e9055169c7b4193c28f157121f82e95b6c8c067b6e39ba59925b893110c048f17bdeb99704957ce26ab241d452ac5cf260736ab76816317658734c3a8e160d305e026b4a909189362516491965a4f8be368bc595afe38ab6be260dfe5844affc647ac57f1d1fb9d4950925bcf4744a7b2e999f11c49da467af3b899601609216c5682a73b9f0b293ae454f8b48aa1dbe1c06535e8a44749850d5d89fe8f13cbb780641a7ded4b9cc00cb499afb1405c070b4c2dec5320c4b4f100148e3a5dccb7b794fe7903365e2f11bd1932fb06a33e0b613cb7e7facbcb0798eaf06ce3926dcad6ac4b02143038f7da9bde1cc6ec22f0e35da4eba4e305ec1d7163f71759afcae1a0fd55debf5a848e9200f71d1306ba2736670c68a47d6207bd99c1f5bc2417410f66ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516cc5f762b1fc08e0e3a3d684f22523a2f9f9766f814159c5e8e4c439f07be7145512fb406d6eeabecf6abfb7753283547c90f9d4e9c05daf400f42b58c901f0fd63c58d643d959db4d79d47bcdea2fb33308bedaa7f4e86a34700ecc09003f59f72d6d045bc59a0e8801f2c95b8f61f8bf02414eafa7461dddc3a7f2f1c0c3a68622a0cef6ef38dd7905817b94949bb07e8a44749850d5d89fe8f13cbb780641a7ded4b9cc00cb499afb1405c070b4c2dec5320c4b4f100148e3a5dccb7b794fe7903365e2f11bd1932fb06a33e0b613cb7e7facbcb0798eaf06ce3926dcad6ac4b02143038f7da9bde1cc6ec22f0e35da4eba4e305ec1d7163f71759afcae1a0fd55debf5a848e9200f71d1306ba2736670c68a47d6207bd99c1f5bc2417410f66ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e90551620e4e586335e0eb752d00dcf405165f7a635c1edac22addb181cc14a93359f88f3d0d02de50fc95628c6e156979b9838e1d538379edf370c6857165a99d25daad4793d73164b27166cf20e8b09f0a87b4bc8cba5a4f08858f54e2aceb8a3ba36c8e47de6ccd06c2e808e788026f567334dddf2a3a847c828f7cfea6de2d11e55b01d09225c2baf362a70e12f8e1358d4a2622af8cd66843cf8a23e34854558a3ef5729973c71c227c4bbcb334d4bb27e1f5eaa462365e00cdf4063e5ba47dea616dcec8a71f0e6c6a1b925caf7366d02e4133c8fef6d153220685a79d5fa053b80c0da5f0525d1f0ea44fa1f57e402e107191f33287d1e9f2211f9ecbe5d3a49f73719af1ed8b5efa1340b9316c088b644b4223929c60d8f398f8ce1763fde4905c0b6ffac3dd0c9fb9006f4f267863a93741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516272ed59699df1710eaddfe6820c3cb488a0d7512cfda7a94fe21abb39b29b6770519283214b54934901783c7fd0e5598e89f8914e742e0afbac71d0ccd58d64090097dd8e598e8bfe9373141d38f2cc3c936ee20fdbfbdce7b3c1de037cf11be99db5b1d4b6b61dc9bc78e1cb755419119a5c3380aec78de081e39498605d05ffa86b8928349bff01c71da2455ab1c0c4236234e1787f7d1a811bcf34d47b04f6a90badfd29d3791080e85df0f24b0f5197a68de5ae5decb11135594ea4fb14c782bac195e1e2164da9d52d8073c673fab4635e8309e8522938675631ba746be76ddb4218e79b8a08ba2d566a239b1bf26cb6a56330106925140ad632c67b3f5bc1335af3fefbd378401d31384f74fea6233dda03371952e8966a8fc02f424b97377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516a6b72dceaa1526549dd2e38efe108efe9ce3c178f094f5fcac6da6fc65bb94472d255c986193934bcaa955b3a2614df3a5ccff68fbeef2a7eb83edd47d52fd3590097dd8e598e8bfe9373141d38f2cc3c936ee20fdbfbdce7b3c1de037cf11be99db5b1d4b6b61dc9bc78e1cb755419119a5c3380aec78de081e39498605d05ffa86b8928349bff01c71da2455ab1c0c4236234e1787f7d1a811bcf34d47b04f6a90badfd29d3791080e85df0f24b0f5197a68de5ae5decb11135594ea4fb14c782bac195e1e2164da9d52d8073c673fab4635e8309e8522938675631ba746be76ddb4218e79b8a08ba2d566a239b1bf26cb6a56330106925140ad632c67b3f5bc1335af3fefbd378401d31384f74fea6233dda03371952e8966a8fc02f424b97377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516acd55e49a5160faeb94d95c997434d4823b881a3a45c0383416bd49b33455c3c62663ee8d53091ec047fde453a77bb925cece4e71aa2e8dde22f18c0b31830b2c8be371d0e18198c279c346a0a23fc4828fd8db97904535e1df88b38042db0df2bbec06cf55488a5f352fdcaf55797a9a22827ddc4b757f981c635c78dc6f1721d394034a5ef17472ec08c7b5c6808aa17eaef41e10909519be446dfd8392ce6cf537d0f079e0fe1fc0e2a16d8a613ade4b3512a66d6f27cfe7fa6e1b8aa741af923cf3a20a574e95e94c0a4b1fbb013abb6f30a350296bf9bd213f97130c4a92a2c3950d6378ac14daddd62e971f5b420d10a03999f97b08c248bc2eed64c66191d5cfc4a972ecb149777191502a02f0457d66caeb4fa6d95631b3c6750616066ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e90551661fed6535a5c047ffa20360cb34183d9cd51932833c0fecb33bb388487012cb24c7897e4ae689fd03a460d381071e15387b4dbab54b19031e93929e87119272a736546177b7fc851025a9a5874a83b89988ee1bbf51fb30e76ca1b5e76cc2f9ad533ae5d00a80bfce9c82b014b2a31149f57359186518229e4cdb5c96e9c2af7314f9c1a6a8629850a7f34d92a830390a93826962718b6e00e84682cb239a71118126ec0146dce01d22677b9287cf895eac63dc4d08933e750c4dc7c9ca64669a3fa4512f444623af071f1188dbcd4160d20e98843e92ca4db44e8e25b679f465871456b32aab2db68f1aad61a4ff995f8fab98a89a4a9626f140d71ce6f34e8a8ef38d8120a66be7952b329f0ce4933984002122a3e13164440259c690fabc0b20423d2d991f37aa6264ec56a5b6c4f61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1619731696bcd6afbf1cee8af86663b60fae05960001174a6f5769180c42bdaf3eec1c85835f9a57f478b1b4941dcf73d14b53e52d4766d51ea74d45e05956a709736546177b7fc851025a9a5874a83b89988ee1bbf51fb30e76ca1b5e76cc2f9ad533ae5d00a80bfce9c82b014b2a31149f57359186518229e4cdb5c96e9c2af7314f9c1a6a8629850a7f34d92a830390a93826962718b6e00e84682cb239a71118126ec0146dce01d22677b9287cf895eac63dc4d08933e750c4dc7c9ca64669a3fa4512f444623af071f1188dbcd4160d20e98843e92ca4db44e8e25b679f465871456b32aab2db68f1aad61a4ff995f8fab98a89a4a9626f140d71ce6f34e8a8ef38d8120a66be7952b329f0ce4933984002122a3e13164440259c690fabc0b20423d2d991f37aa6264ec56a5b6c4f61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16496f83ea868a2002b2f6db32625a6ce9b8459576169819407abcebbffdbbd25fa31fba52ed52fee752e4ae150cf4618ac04750cb70299a6a2108ad12bd0a95d687b80587871921eb9c433d81bf304c1bb7147f0065559e92e797684321f6cc14665e745cda95e5b70a86ee4a8dbf2f25f6cb64424c88c14019e611e990b4b366ab4b4cd38a5f9bc4b9ddf94d46c5e3eb47b7ede14c968c8d38688d37976d707ef511b10f8229da46870968bc18fbed19879b00aae32a5e775dfeed16dfdc945f294b51e92847a6ad223d8a31e303090cbdfebf75bf602113889068813bf381c558cb63ebbea93ff1b9fc8381ad9e01bd67c9a64ec443a2dd4d0c7fe1b65856346baac4a4d047cc2cdbb8221ee6dbaa2deb8b09e40ac406699a9d092e46be4fe3b697ad59211b61e6abb9f581d9668feb63da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16fd93ca8fbb77e5e09065926ff5fdb4a49d01cca7684b355792d9a66ef589b936abee8f0622fb1a5cd0c4018144e50fdde202fef5d0fa407fb8cf63caeb9adfc5cf31805f989751bb921a429abb16103a81b546a778cf45f6969dac5e47aa66545cda53640cced81ce4f562ca9dc49e0f08a3a5732ca02d3ca3ea9b1ffe4a87c4c81d5aa3bf7930d437965754747fa59d369eb61302e2f1a9fbe66c9af096eb308e1673026a9ececef9db55393b9f479858c51c07a4903776e4d737ff4dd39f751869182327ff0deffd68b7b3e3d36aec54117e15b8f6d14a958130fa69e8fe5f54a71230c1dc7fb5a512e1abdcc092f3116fdc20f7e82ab49f2b3a21655afcf500bb351ca61863907333ee14799e69bd3b19700ca383e718bc41a324b79c671320bf4af69d8f8574cae37b7974a54b6b589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516009ec80c511e02ceb8a86d20a8e1fbb79d01cca7684b355792d9a66ef589b936abee8f0622fb1a5cd0c4018144e50fdde202fef5d0fa407fb8cf63caeb9adfc5cf31805f989751bb921a429abb16103a81b546a778cf45f6969dac5e47aa66545cda53640cced81ce4f562ca9dc49e0f08a3a5732ca02d3ca3ea9b1ffe4a87c4c81d5aa3bf7930d437965754747fa59d369eb61302e2f1a9fbe66c9af096eb308e1673026a9ececef9db55393b9f479858c51c07a4903776e4d737ff4dd39f751869182327ff0deffd68b7b3e3d36aec54117e15b8f6d14a958130fa69e8fe5f54a71230c1dc7fb5a512e1abdcc092f3116fdc20f7e82ab49f2b3a21655afcf500bb351ca61863907333ee14799e69bd3b19700ca383e718bc41a324b79c671320bf4af69d8f8574cae37b7974a54b6b589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e90551663f2c04358466b7c1e198a0120b43159c1d857287bdcd1c759358ad92d5066619f5a9c8883dd2ab0ebc00818c6011aba8ec74c13d79061faec76cd383bd8ca944d16902585adf7787232763f769976fdf4991b8a552934e4ba94b15cd78e69434db2a4397f5ce1cb389987781458f25a716b4265ea5dcd5d411a7256d39d180fbf6f5742917c0eec3373a51b81e1548618eeffad64c7a858b96e5b2ca75c3e058d50a5fceb015fcd91a7536aeafa0ef74cde4bd827fb04f6b4551d697889fda5ff2ba525bba9d136dc2ec12fd5fef5131156888735d4687f5bf4aa51cb91a34ad35d8453d103fdbb812a90b59cc0478a6e8222768b9b5157d175df69379eb9c409b1c2b08995d0664444ddecca7af576a95872c5f83946fdec8b0a720bf19ef07377944275a2708aa43212e54c441091589510bd83491a3d33c43ef3aad3e2ef474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516004f2593e9d8ac8d86c8330e3ae557ee0a9571eaa13b17df41acc06a077e62ebb24725926bc796f5f5f62081402a9c23ad97c1d859b01f58a262cbfa9a216066ea24569704867e4910f14ccbf532ebd5601d543c0b0e0a91d37c0c533d184f9d88e87a15e58a9ee5ceedd096f0e1cacf3d248c43177f53aae5babb4c04cb7db1d85d13d5643d51edee67cb3001b2d2eaee448d0496e178a65b6fd889c955975fc8aedb10e878bb5b0301963dfdf33a9fc16ec0026d6eed57128801d45e314ee1435b3d66663fc8b2d49f592421b8e9fc3af86fc37bcffb5dc1c58f01b4402a1db5df00458050fa53854a1b9455024040faa48f4440ff705d385caf7652c3b5156de3b174b6fbd9d12fe36d3b8c38919c061107b705ee72ca36f0359d4ebe985bb697ad59211b61e6abb9f581d9668feb63da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db168c11987198cb2ab419e6f9a4699ffae475ba6459e9381b02bc9aba714376458ee22e9efa1758407c2b596ad4a6476095019a38cd1353caad288b77e25267867a2f6a00365df84cf57edd5f6050a6671b6f067ce0fbf796a062fdf39e9beb0f5f7ed92b1aa8ad0efb332d7312e0984f9e3d248c43177f53aae5babb4c04cb7db1d85d13d5643d51edee67cb3001b2d2eaee448d0496e178a65b6fd889c955975fc8aedb10e878bb5b0301963dfdf33a9fc16ec0026d6eed57128801d45e314ee1435b3d66663fc8b2d49f592421b8e9fc3af86fc37bcffb5dc1c58f01b4402a1db5df00458050fa53854a1b9455024040faa48f4440ff705d385caf7652c3b5156de3b174b6fbd9d12fe36d3b8c38919c061107b705ee72ca36f0359d4ebe985bb697ad59211b61e6abb9f581d9668feb63da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db165914f8e7f9c5e838ad0a80135a9e3c9b3edc758cd89496390750ddb319b9c923d7454b31741af21b868e2ed86bd7d42ea92e924974312d3ffc018df8e56e13f622507d6656519a2584b83e7819f8c39dd961346e071e5b0370556f59767733de7d3cfd14369deebd6d5cea4211851106deaaa5b9b8077853e83cb46f7d868477dea8f310a903a7f8c9bc1d905906ca49d7403814a7f654334188e3f2caa66e0952a128e19d7c3503df12cced08edb499b59cbb67be29147a48902de711d39e97d589f49681256a0b8f9a55a55733343c74863009f7e068286abb0fa22a72fa3e8c819f727d9915e92fc90f76d1251594dfab81528b54f636883ba4d44f9207a98cab485bffbf5453d9bd24e5bc582254fcb8efa73bbfdf1f2af4b281d144c32c05c0b6ffac3dd0c9fb9006f4f267863a93741867098dfc42b4136311ba095b62474ca1af1e45c99aa96f4833d93ec26cfe7dbf64e4309604408a599d300e905516b0fc7f1a4e79be2ec29d581b4b9ff0990ccd5b21cd7e70f70138ed1f2347e156f9488aad617248b3a82575e2cc49d54e33fba2a73ac0eb3ad016557ffc0f9281b865a435d317c854098214f03a6ce8cc4d3ffc901f665583f00aea08deb680866d877df1b00651935ae8ab08867ea6f298c6ad1ef5c6171b5c6d5125007a46c62f5e1da9e7fb20b8b32de4e1a3db80be2511e3f5e6612aaf3328e761d772ff0aa22b21713c995e005f917659bfba867869e90873d81e07791ba1bca52dc8bd4643be4535de65412f0e087037a206d164739f6933c2e8e864259609a5a759b26fd9ef7fc4fdca2450b64e6eba87d3bcb2969cd61be54896a28ba69c891efbd1afdff7716c6782449f85e964e8d8a1c360984002122a3e13164440259c690fabc0b20423d2d991f37aa6264ec56a5b6c4f61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db165235d9aaeec12e916a22eb669c7e101dd3b7a2f87fe5b68c0b0d29c1c82a5d92283b07631523ea1dc1a62d5513184c49315853b8418a8c7de12d5e7b7bbf7f14b865a435d317c854098214f03a6ce8cc4d3ffc901f665583f00aea08deb680866d877df1b00651935ae8ab08867ea6f298c6ad1ef5c6171b5c6d5125007a46c62f5e1da9e7fb20b8b32de4e1a3db80be2511e3f5e6612aaf3328e761d772ff0aa22b21713c995e005f917659bfba867869e90873d81e07791ba1bca52dc8bd4643be4535de65412f0e087037a206d164739f6933c2e8e864259609a5a759b26fd9ef7fc4fdca2450b64e6eba87d3bcb2969cd61be54896a28ba69c891efbd1afdff7716c6782449f85e964e8d8a1c360984002122a3e13164440259c690fabc0b20423d2d991f37aa6264ec56a5b6c4f61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1633eb74066b383014df24c4eef7dcef92aaf6fdfafd410d37f403952123859a2e249b9b4d28ae705ed46f8c923aa6dbc73c8a1eba83c1234fe47c40cd7ef224ab3d3247253d6a5297e8f91baaef7c111ff900ef24f493803fbb2ac390ba56ac76b55dd00d290d185adcfea1663176e0e420d4b5be594f1a4ef949b034b8b4ee7fd068e59bdf49ff5ba66a2b0291ee1959f56a9cce556baa43160ee25b4de55d3f032a3ac867e81b662fcbe437e9925ffdecc2229b577ec523f20abd4983825a60f8959a8d4b443e84fd6cee2182ef5419e8273447e841811a6e3be44dc75b3028e97893b490c3693d6d31d20ea7b5dfe3f739dff233960740d7bc553b013e30e50cb30c15c7213301e6c9b41e9abf3e2bbcbce1a7488bf8bc6a1c9b4ddc020567418910ae95876c76224c03c36251ff8716e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e905516a70353a66a40b793cfc9411678679406ff044b0715d08a64ee7506d21bf00ed11d55d8aa95f9735f8307f35e69d2469d246b118fbf2e17d18079586ff65a53bc5e1f6684fe163c9d2bb79748a304c5c466b30ddfadde0166a1d4b12a49d468760059f457dad3493967506ab955bea56f1293b2070e2c5df8320342fff0d76bdc1d506f665e02f71afb79a0ca2ef4a6aaecac5c2a3a8bb36b71412480f72194d0c70007addf3be4882825e0837d162ffff8d551a90903d9687891579c473bf11455fbc5ecd9b7155dc4adae9ddf9c4f099d28caecc98f7919be1e691e8a4d9ea618ad26c61b9739eca2fd5c225362e61e0152a46273cb124821fac4f7100af92920e1b3f6932f00945113fe7cf7c50223456aa74f87d35120d414388aca0f8a8418e073733dc9d2e6f1b4c473add199deda630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1667018780b5078e36786409eab89772fcedcfbe1b6c947f8de8a1edea36f8db6d1d55d8aa95f9735f8307f35e69d2469d246b118fbf2e17d18079586ff65a53bc5e1f6684fe163c9d2bb79748a304c5c466b30ddfadde0166a1d4b12a49d468760059f457dad3493967506ab955bea56f1293b2070e2c5df8320342fff0d76bdc1d506f665e02f71afb79a0ca2ef4a6aaecac5c2a3a8bb36b71412480f72194d0c70007addf3be4882825e0837d162ffff8d551a90903d9687891579c473bf11455fbc5ecd9b7155dc4adae9ddf9c4f099d28caecc98f7919be1e691e8a4d9ea618ad26c61b9739eca2fd5c225362e61e0152a46273cb124821fac4f7100af92920e1b3f6932f00945113fe7cf7c50223456aa74f87d35120d414388aca0f8a8418e073733dc9d2e6f1b4c473add199deda630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db168cd2a03e5e48e1ba7d153d4899ff00438bbb342d5e6a71031f1ca39f63ec6fd253faf36bc84580c4ccd5cc050b47cab681553a45b4ad791d8bcf9aa07e20ba3a4907f79fc57ff3456a088ccd94294f037df13197a89f09b6709c01c9adb46fe4d031d71bdc6871298d0b32a0b4094a4ee195036dee479597417dd995d457d5b2c40579f19e02c7e948d47be90c9604618a3fada61a14c6ea41589b1c182297e2290ee2facc7040f18e0b357975d70260a06d47f60f94f9c52bead7fde1a30ccb5fde69d5ff00b9199fc6308c9d6933b87210e6b9e18eacddc6364fafe7c9c1e1e8d402838caa98989e52659366b7d6381f634807c1126f1de52ba28f75e16d7a22dc0818ac6d3b2f523c764abcc158cd76314f206cb489b8f9d7a8155740ee354a68928774bf76edd70045335fd192e663da2717b9d34857aea6c6b93aaeae8143cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db1616a0a1e63f7859aa638549792b60df91f963a5b256ffd4ff54d490b061c7f97e1cf78ac74729e7b55482c43b1109faa875be57f6f809f512f79d121cde70afd970f6ce0718376dc26ccf7828a12b499b8088a863012dceadf311510b81da849425a131db436f2c5379a6b1621d2722227e6a99e703fe032128060f4fe8f62226a6a4d465a3f796e17bf2195a72cf58a9d1f5ac896eccba95275402cd391733f405b256cc8cfcc56e82d1965f4593e1baeb947741b5d4125b93d032cf3ebab6c84f558e8099be7cf5e813b1ebcb2d250c7db53d9345261237f2b0022bd9e7828fd9bcd054629e74baa914bfb5c9c709133e0b8d51d8fae8346360a5b68f25296a31b1bb7515cd88beeef88b576560fffa406a551269b5f48a7c14269fb1008fe182cb55102e48c8595506b72cb557f41a19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db1629bbd3492769c7a2fc991c710d20ee2c9f262b5dfa66d0d6c520b1a139bb9e2ce42773cc7079e5de5310c4ef4419edc475be57f6f809f512f79d121cde70afd970f6ce0718376dc26ccf7828a12b499b8088a863012dceadf311510b81da849425a131db436f2c5379a6b1621d2722227e6a99e703fe032128060f4fe8f62226a6a4d465a3f796e17bf2195a72cf58a9d1f5ac896eccba95275402cd391733f405b256cc8cfcc56e82d1965f4593e1baeb947741b5d4125b93d032cf3ebab6c84f558e8099be7cf5e813b1ebcb2d250c7db53d9345261237f2b0022bd9e7828fd9bcd054629e74baa914bfb5c9c709133e0b8d51d8fae8346360a5b68f25296a31b1bb7515cd88beeef88b576560fffa406a551269b5f48a7c14269fb1008fe182cb55102e48c8595506b72cb557f41a19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16b539535a105f2cab24c4bcd8cc34b40931708009c7f7daff73e7f5c81b673bef439426df2f41b924da109ebade07fbb8e5c5299ee236b18896d4816cc6a2dd1bb81932cc8fffabcc836b45a7a69be07baa2321eeaad6278f835138a512ea9f098321bc2218c77a0cc613ac0fa21af407ba7546378d67860bb55785cf0cfc24145916e1a0672f2afb5fe1a581d292dadf15295e212c1dded57d3e16f61e2d8fe0cfdb8aae89bd14a069dd9dbbd939fca53cf22df3dbc86e29d9c471dcbc9f8d6c5471d6aaa25b418397f709275e0bbd5805a1054247211cfd30b6172d9eab8f381717bb4d55e849466b170b3566d8e25c9995f2b718045ad74ff848f72b9ecbbbbea20b744ab2698ecd94dda667365a3e50f06668a9d683688d6e8fcc755fd834b20423d2d991f37aa6264ec56a5b6c4f61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db1674b7ceaa20337c726cc4938dc696b3ccf69622ce8d3677692dae0275747fd7a9c9bfdc8b521bc17c48e08643c8fb693dafc43ca7b1d2003ee695cd1f120f7ada73650b2c421d81596fbe5066f211a0f905c3d30ee0eeea02c5523ade2f537e67fa0d532f8fc53144916e142091dac632c137996bae0e277d8d1b7b1958cf8c817975a1ed7b18c9e68dfe1a96bab639bf23ce4f8667b718218651fc85cc9c6594bfd8cc1ada00e38583f0f53c09f408418a0c6ff2261d29aaa75a79006717e3d44b3c480696b5e3d92c40d31636c325119c36aad6c79032e2723cd0693507f9c3e7563c5c0b2223e5a6e7cd57eaffadc35cdc8081372e954aa352314d7b36b108cebc06b32a0023680feaeafc6f127995ca90d66da9e273aa483e9dd1f08bdc69e215786999fc404c39823fd1ae69998b19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16e515d111a1f8524edb94903f6bb5bce66876ea9b6924bce549e0c2d79e87c277ecd348bfeeea69691a3d311b0fdbc5692f68064981d4ea18559a6fdb694c99ba80ac915deca3bfe9a612b7b5fedac9a48ff77678eb21824245e96e747189eb45fa0d532f8fc53144916e142091dac632c137996bae0e277d8d1b7b1958cf8c817975a1ed7b18c9e68dfe1a96bab639bf23ce4f8667b718218651fc85cc9c6594bfd8cc1ada00e38583f0f53c09f408418a0c6ff2261d29aaa75a79006717e3d44b3c480696b5e3d92c40d31636c325119c36aad6c79032e2723cd0693507f9c3e7563c5c0b2223e5a6e7cd57eaffadc35cdc8081372e954aa352314d7b36b108cebc06b32a0023680feaeafc6f127995ca90d66da9e273aa483e9dd1f08bdc69e215786999fc404c39823fd1ae69998b19a1d070a775067f4be2eb61a5f8bd9743cbb4ba65177effe3f4e140e28cc2cb829ed91bbf91cff07f1f76b193cc61db16c5d6e3360457a134da7ce9fd4c90bee5c7a310662f12642716f7712209c7ed1a417f7542deeac0c65848ba22b0e4332356ee41ada6077d62127d47030c84ba6d915c87ec4e1715b780bc5cc48cf24a9c773f11f38429e074b96df5654aaff81781c155d5be57b69e5a25119fdb7e37c9b992a343522e3f038553c0d979805a8f96e1b423f378f821e9ca35c785cbb4565761c78c93c81fe04dd6af0b2dd642aa4bcf1c9a7ca0702cdbae89985ea87ab01d417ad27b9a7d468819692b62e8336a65331609fd6ab00e59caf9934e80aac4abb6f30a350296bf9bd213f97130c4a92a2c3950d6378ac14daddd62e971f5b420d10a03999f97b08c248bc2eed64c66191d5cfc4a972ecb149777191502a02f0457d66caeb4fa6d95631b3c6750616066ab82aae5780078549cad03e4a191a516e7bede3dca2175034f8adf3adf5eaa9a2535953f5fcb23752e789e58dabc19fe7dbf64e4309604408a599d300e90551676736363c246aefa459484175b888be5f0c340e0d7c22c7033eca3bd644987cff1fa85b2c730cd9a5bf88f46d6d434fc6a311b8247ecc26b7b8d3dbc2021344d00dd7d2f051fe6db572338160f300244cca8b910f83b103482d52e52f4ac114d3b83ca800b4fc8245cffe6c197833d607d1340859602e9d3b66839fde13bebd2511abcf10164689c07c5bab845e1c3a51bf1a87aded26c788e5a5951e8002134e835fe5c12e747b0605925428dd80aed90d0a9d2e8bd7e4fbdea497ecf99ed9efe2f15eba840be851a0c201236f440b101c7da505d52aa2184290be8c57303a4055a48355fbd01fb6dc8e74cd9c2d1cdb12bcac7e8c11c6530a97d47fe01820b24f7c6a90973f3234d3f24a5d4098c038917262c82665815b77dc2ac6b520a0aa334746f3fd13cf79ff9db23ad7b218b61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16ad6513cf376b54a9dcf04c3766c9c4d1f0c340e0d7c22c7033eca3bd644987cff1fa85b2c730cd9a5bf88f46d6d434fc6a311b8247ecc26b7b8d3dbc2021344d00dd7d2f051fe6db572338160f300244cca8b910f83b103482d52e52f4ac114d3b83ca800b4fc8245cffe6c197833d607d1340859602e9d3b66839fde13bebd2511abcf10164689c07c5bab845e1c3a51bf1a87aded26c788e5a5951e8002134e835fe5c12e747b0605925428dd80aed90d0a9d2e8bd7e4fbdea497ecf99ed9efe2f15eba840be851a0c201236f440b101c7da505d52aa2184290be8c57303a4055a48355fbd01fb6dc8e74cd9c2d1cdb12bcac7e8c11c6530a97d47fe01820b24f7c6a90973f3234d3f24a5d4098c038917262c82665815b77dc2ac6b520a0aa334746f3fd13cf79ff9db23ad7b218b61dd197bb77674e8a1eee410d7418f7350800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db16e1834931fd55df9b86fe071c39a3215a3e25e8d8955e7e4a3475d5f0155ec7fc74a9114744f41a93dc53c266150070bc5d77f1969e0c8223a2999c8731eff7353b33c22ab78544a0db4b30146dc2e37d82263d49f7131fe816ea8c297c08dadcc2ad47a6ee140a3939464f0d475ca834c37d8816e07742054a286d69284b3128f9b93aa4bbb6c88a945a4ab1a448534768b7246d75bce72c99d6fbbb338b80cb9d07d7b922621dfbe7207d9f1ab036da5d0d6d753c7b4c14a0c5b2e80d5ee408ecd8a1af8d43471921d6d6a94819720a15d106fb606ab82ce98c0729c3b16c7feaa3760dda39f77e1e13927a623ba6ad2cb780252e9ba00e1f010f4ea0e9622aabf884efd3d5ff9e5e1a415b08522829823ff41593ae6d7f892140acfd46875a18e073733dc9d2e6f1b4c473add199deda630d4651caac09f242af07a2b7f9e650800bc856a21b2feafe1b8ca7e3b7b4829ed91bbf91cff07f1f76b193cc61db0301000000010000000000000000000000000000000000000000000000000000000000000000ffffffff3a0367ed0104ee2f375c0c0000000100000000000690245c4e0200000000001b324d696e6572732068747470733a2f2f326d696e6572732e636f6dffffffff0730da7353000000001976a91455154ec4385f71c4a284731cafaf5d19406c030588ac8017b42c000000001976a91485d09684f9127e1cc075a81ed9eae00231edcdff88ac80f0fa02000000001976a9147d9ed014fc4e603fca7c2e3f9097fb7d0fb487fc88ac80f0fa02000000001976a914bc7e5a5234db3ab82d74c396ad2b2af419b7517488ac80f0fa02000000001976a914ff71b0c9c2a90c6164a50a2fb523eb54a8a6b55088ac80d1f008000000001976a9140654dd9b856f2ece1d56cb4ee5043cd9398d962c88ac80f0fa02000000001976a9140b4bfb256ef4bfa360e3b9e66e53a0bd84d196bc88ac00000000010000000106be6afc693eb158fa010f751c347982851108ea316d0685109caaa8ec4580c2000000006a473044022048dcdd1121148ecf495df74f563384d91db0635dae3c608666511ef8f24adc19022004af5384be1bce1531d0bf8498a8bfb150f797e63e348ca3865f0322203384aa012102d79d7e7f40e091517465c50ed3f5345c7b9a4611a98103ee1ca1f8e66686ad9fffffffff02e04d6cb3160000001976a914b923050a6542d70d6eb6546ba3275a2b6ea0eda588ac06d8c849000000001976a914c353c188620cb33a72d218c0d67fb3f92ad1b6b088ac000000000100000004bc566f299c27644851e70f8646f2ed642985897f488090f5bd5b152d891388a9000000006b483045022100cd5ca45783f836db65a47ba568f97f890155c27ccf0769c1edf231bf6bf49f9b022021d8493fae950bced2c417ce6c4a62aaad495c4d8675e4a4a3e9d86ab44f4b29012102d91223be36388a51cef618ec34c2a1d95f85b6d85d0e07e9e963d84893b5cc4dfefffffff117437f28af1665a002c8351e903de10bd40ab73d54932bbaae28caa6f49a42640000006b483045022100962a7c4b0a96b25fa418f92a69e9f37c5517740d50e996a92724f064bfadcf75022073f1c0475215bef70de392273360655a0b0d4f1f8afd3917614a5dfd1ddd1ca1012103bbfb869bf408cfdc237c63c1dcec226faf56cc141c42ef6bb8ef4d156df669eafeffffff0dd680cce849762fa7f3f117a1b0e3e7fb6493f2600152143502aa59ad7ca4ed4d0000006b483045022100a6b7befee6468f01e2b3813f05b81241dd5a4c0b04d97a783940c75d97b85ba502206c0dd2fa5cc5c153d359c55f24407c97aec32bf42b2723fc40374188443bd4b2012103bbfb869bf408cfdc237c63c1dcec226faf56cc141c42ef6bb8ef4d156df669eafeffffffd7e86128b28dcf2046c33f098cfcc2b7f08ea781cc119c0944b2fb23e378135d150000006a47304402204116539acc084920a86bf837ade9d938b517dfa0d346beb969cc97505e6f21520220069a9a33f798a6c1912e29568be2cef12d4db0ea1db751054db5e2311b761854012103bbfb869bf408cfdc237c63c1dcec226faf56cc141c42ef6bb8ef4d156df669eafeffffff0229b90000000000001976a914da8378d88cd781e0a6f9fdfa6668dd47a718cf6188ac80a35d06000000001976a914707077511db03836716dc01ce47442596264fd8a88acccea0100 +02000100002c01d50f651e983d8ed9b8af996ad86ccb59f33732538a299e5aa282e3b60f285227a45ced0d27cc8dddfb4d6a59734e9180550bfc6fa2e626b06caba1bfbbb42a57580ca5001e400000230401000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2602a330062f503253482f04ba2a575808500088c7000000000d2f6e6f64655374726174756d2f000000000680ac89ee000000001976a9146934fe23ac758cbc21953fadfab01dd3671c01b688ac00c2eb0b000000001976a9147d9ed014fc4e603fca7c2e3f9097fb7d0fb487fc88ac00c2eb0b000000001976a914dcf01f01f5655c10d4fa8149d71cfee36313c02e88ac00c2eb0b000000001976a914ff71b0c9c2a90c6164a50a2fb523eb54a8a6b55088ac00c2eb0b000000001976a9140654dd9b856f2ece1d56cb4ee5043cd9398d962c88ac00c2eb0b000000001976a9140b4bfb256ef4bfa360e3b9e66e53a0bd84d196bc88ac0000000001000000010000000000000000000000000000000000000000000000000000000000000000fffffffffd3f5fc2023b5f640000004616a95119fad7cd01a172c1ce73a1ab0572df3a9476fd7351fbd3521f20e43ee5a113d110ffb64fa840cbc65b946bcee0e9d5260936af7352180c8d2e4bff6ae5312b3693800881eca7fa266f9463268047cd1a69b6b768114af1032deefc3f4a82ee313675f89f1be50abe40d7535d4af86fb8ebe980724d6544332aa196d2ace08ddc18c9d83355b0fe38676565663c709c20c5cb49743ca4362a6fd1b3173e5f268c09747420d0e9f7354a3b09c26c18e99f0f90fefc56bb9aa4c369926f49d7b0c56b6005272a2023b07a9f8bbd35ec64812b2a806a78ab9afbca49c1a46a18dc33aaa0c6d93848fd6902b054b12f76c26be568e13125a36afbf200c0c4c1e9506ab13991b09837ff73e015a29716f455567ecee8baacb33b70a55ebb2d1d79cad0a2174f6ff0f490cc5d761370b325fe5b5e42dd94df8fa4245fc6adbdf327d92d587447330d6069b336a683e4d98c1acaed4e40bd9b5dfdf788a48dd7ec5d45dd7e257541a1c0e08fe70efc7bc023977e44917c1c4f86b45db85241cef46ae44299ab34e206327f8a0c503d995a41c2b557eced8374268466bc50de7326f42562c2c662e9f30deb8451bafa46db080edbd4eb697b2e6b8554b3023306e42f11f35f98350a8e73612b90ddf2dc3a58e59ee4c86a059513ec4446f8f8b48df23dc918c92a14bdd186bef0cbfed1f9a0105c9f8d7ebbeefb195a44c4b2b84ccc0901e1e6fc1d5b02a82887a0e6bad11aa43a4bdc6de51adc071c7ff64492e7da6a7f913cee02695cc2e1e5ebc8e0ba718da6e621cda477edb25d072c52be907f3c6ab522a18541cd2ebd3496400286fa60d8ab9b65156bbad30d199c63f6c0ea171da7e0496fd43087411f58fbd5ec8c4469f30311c3161e7b857631b95f34f5dd0638f7fe81d16e8da54a6693113525bac1abd3dad45205eef6c85e59b2f8283f6c2d56600ad12c0bd26005d9c9c4ee87290de38fbf31524537c14fad7928dc56122e6600c09f4a73a5f7329a3e8128c4c78a954e7e105a604e258f82709adab792c8d1bd46784ac3a502af27590c9357cd3827e261d34108329814a0789147f765cc72011999a26fa9c4f54eaf6484175b9f870b549c9a74c4d9ef6d0dae0089a96b65a38d75222faf43f1b0a9d91b265ef8b5fda1d23218f736778b429e458ba4d76690d76e77c23177cdc3736f07fb6d0f02fd69027cb5d9d005f77209d09480886ccdc558a1e8dcf95b2641adb950e926563109846e6e42b18e7bff385d7da1fb543c9c16bcc25ad0feaf99d86e167bbb8120abbc0a8fc871a0f24ff8d5b734ad823484762b5addc8601aa754a85c6c1a1b6fba60196fe666f14d9d0f1217fbe01c5e02a218cc40942527bb49ea3e110f22364e57293105b627d2dfab8ae558011db98f486da414f9ae7ac4641be9b44c1708bd9181abb9edd23b7e1c88077e2fead59821b19123a005e19d2cc6203f2e90d3d6c8710872ef06f65b655f76290f75d600cbe90a418abc7a49cbcf0e0a11691cebc7342d026cbc37c9774de3c42b155d0ade19275d398e2c5502cfd08af203d663b340598ad36ae5d054c3794da4761efebb56e0c1a19dc79e7d16617049f3fc5d3f437056c7c36267c3954d237988aa23499c04a8c429b32271d977b396267b8a0d6de67705667784b4b942136b90f6bc87848dfab43d94381b79b31d0a4cbbdd9384b3831ffc7a059568dba79ee626c3df41f5523b06e478b0984071883183a39eb9e0ece8a3b95983a6b4d657c69cb68e598340ceb8e6e106cd25d7a3d65ca3df6dde35c150429f6081bee73277b5723a5ffd5ed271e2d0fbcdaca03c2862df6593b873f8916b05e80649681e90c22078df64f3290a3a166a33b0733e321335712b8b95f87f94fe214fd0deb07610ae5d330d2014cec23199c6fb66d765c3a694f75c0ef5e5d5969bd9b6592e1aac30ced013fc0569df8c742590780f4e57e89fe0f5a60b08b21561f2cac0847331ebd7ab30a193a7e43d6c8755043bb81cdff9e6993cfab2b12e0ee5525944fde48d49da4f66f2cb37db3ced76c17cc037c3343af93f5ccb66d1b602fd6902ccab55d5f7f2ab3a70a02fdf66f6a15a1ae9b126042e21a8ddf286a4975740ad012362032c121e6d57cd7ab8babf785ff89f147a7f07d8145569f2e8c77cde6cbd449ccc44e73f10368a015cc5831f3a9d264f0712b73cfa07d9803d6825c06fb88ee8ac0f4fa35e3d690958e7a6d0b22a2ef2ab6a4f9d6c1f18692c0d0af63a327b307062369aae0c2b039ccf7496e1a64a84e849a4dece3d6dadfe334aed37997973e8183ec3057c9aa45c8e23632b70c0d113c18591d013bcde7546ba073005b5ba5d321c9cba2d6fb8b218fa1e6eb90a4cf4841f51ae58faeeda5890daa368df7e70c28dcb1977b757d979b5b69a38233ac7d08832c7b41a882dc6cd9810d416bce6e889ba692bc05e145087088cec58ab6899a161781c0edebbb4d0201dc56487d23b42fc61eff8e6639b3816899d0235b29a90a7a9af10f668e7ad782f9d4bd7e6b3d04be7b96f241f422a6e9adf83de10ddb8db6a6826724692da8638be16034ad56e0909d2491d884663f18641d7f823cd6800eac62e3a233e0205624969454bfdccae711b5227ca0340a5bf4d5deae10aa6af7846e012924eaf79963e93ababb25e4998e08a51e22c605475014807790e1a028682a82afd7327661a1a50f40e5cf2bd4f07e17d81bd7302756efc47faba15a4ec1b90763f1a343a4803c23421fa5a3abf5dd25458044b1254a9bc4ee50c4d66ea2516c84d0f0209d93475a0a77080371f79ead7b30b05684fc0cb55d86b7a77a1a9b3a0aaf41bff606d5fca2aaed4e393abdc83798d9b76886a939b80b485c05b21ca178e1758153851840a2237617de497dcef395d13f5cb6a197b7f43a3aa781b0c84047a9ca5228e7dd04064ed23a800464266d7510aee1c07d9967cd95ea4c7436d5dcb392be6f54bc74215405aa30ae759ab3afd3e88ecb6f5f5f68e56b7426a52c2f684401c0c97a3c2d516a5448f9ac6b98f8b6a0846cf804ba0dff72afc113b7c7cd2707890fb6f5d6cb8c297a3942a314162605e43587152ca1db9aa03c64bd13a8f3d58f709eec8dd1c3b6524b6a275edc71366c8267c796d8007467326e086ef27b8a703ffe19ccca05dd1891d0e0e21084966e24b2fde6a425f8176f4d6c288986cd45ada136fed8b916b233bd600a478d72f50c4ed6bf60fa0cd79b3cfaf6b04fd35013f3592c5294bf1936e75f4125180c0cc481c111842ae8405ae43ce12f3b65e94f4b9a2e694c69dba85ec8a28028946c035fd6f7d711261a95ec8bd7cb9f5ea1e4337d14c17db425a41f356b33aeac44781f452f038c6bed8225494bf3a311d71ba62781cc8eb006a8b01a5c6c112df17f8b33233d8d6dd1ca57dd3a691a3cad359bb65685bcffc5cd72c4f662c4005feca59957c3a7126568d9fc66ea9e2e726a356e08c7040ef0b80a12344d378cced4851a02eeb1a764b6f28866f7c959e4bbd76c2ccb0e43c88c08ca7f4be590a962da6b2bb9e81ba464968fc9dadb34e7475ca286bd3bdd77c8d52a4e28b8367e31f9490ac4c62c2a0360c7d10f56cf45aa7c4892787cb5863adb4b1cdf01a8aa49c13602e1ee120faedbbfadb96ca7afae541ad45304550dcc7b6519836ca05d143272a1202fd3501b2e6abc6cf3e8cf0bb93edd58b05ce2ce5cbe3554cec45bc54c6588ae86ecf21aa97c42f16593cca2816ccffddea3f4fe2bf277f99e441ca944d5c9b4b1a2bd3c36b1201b1459744372b0c0553a803c020d90a87bdaf3c1669533b2201c309b233f6738ad3e3758a0a95a4ec16f8572cdab792d79a7c333125ea65ef5267d4d331bbf74888254ce72de78967ac6ae29b7853ddd2a4135d235f4d576d490e882e1396aa2794bdd91abb5de7a5bf32666f86e861fc99f1a99dc63228c11254492a06d8383557c01b122281d056a618ac7a12e7bbdb9205e978efe91c87366b8d78549990557086ef29d78f8289f365b6c558cfb27cce40738109fe053bf762651ab16c32f5f792cd63bbe8fd6d14746510f7495d6f64132f3904079ba24682222196f3b36deec88ef34dfc2d13642539dfd167a70e02fd350181715d9a0de5f37f6d4bf868ad3cad23cba750c664f15aad60069fae05cc998328eac298ada7054d9374fc0c11bd24a435f1550727fb5777b27e791c9140682e3cc4351e93158746e9d3b7c5a9a963384e0464e6b908fa392adf76292e42dff2f70fdc6449cbd079e744c11d75223dd14241174a378bda256381a8e26b0f75851d6cac15673aadacf98604635322356f18eea4c7539bdbc46fea5162a273fa823ca6900219dc9dc125b58886a70ea5beda5c56d911a2609a9cfb134091b6979a5bb6495ce33ddecbde77311095696aa41a5b1a8ac17a40a4604db3786a3c1cfa3b37f924627040a3359de13fef3bc37a66d652879a20825bc31fe587fac3e53addd21a62df4927d0ba3157d9a06f387bbe93706dfa694840c6ff1c20102f208d1e56cd6768e6fcef107630d8c19dfdda1470432501fd3501d971401b7ace48206fa5b6f8f7e1dc0099baa5a86dc6dcd6e0c16bb8b6ef7f801d15cd440fc770f0af8dc615c89b9e47d1de4deb9770706a90ce0576ae9fe4f5b9ac0842edbcfaadcfda2fc2380db5e75fe19521bfdd15b89c6f1a46c5e639b1733002b4208f5a94ff4e630ffca18f78d7fc34a34604f97b09436da6c9d15d3677b7a0611e329177a3feb95234e2c125a6a451750582080aa3c9ea0eb223d5aa9e37c8206f21c1bd21f1430ab948d94c6553cf6745de2fbbceb75eabdb6aea5863403a4e58ca00fd4d0d4d5ca5acce481f7c3b84dec0c76054134cf33c8fbde7729ca60a8d5efea34cb1fb7a58808d262895b2f2337dbfba439aacf8f52989c8bf9617ae1fe391179936a584b2f54d9eb7922f1fcbc11772a2a25c9c80861c8a2d8934684ba9b13bf34ef5e0cefe9fb113778b3302a5a19dfc47239ccf00ca3869f1c0762d7d4aa950495d9b9b6f399b5eff80f533481541597cc1f28ce6051bd1c5968b288994f241581110838d4b2672759510e7a6182b530d53e965fc2a0011f5c8c803569d87d854477c0b71ce794bf0130659e1b4665f988825a0938a330a65de7b8a35294d13c894f231a3aac70f41c2f7436433fc0367ec803829922ba4e01086fea283f307e8e7e4e7126ac6e47ed7042e03c6c9059f80fdd40153f280d74832dde2831374b9e5e3919a10314d8ab9449d438d818ed7fff1745dbecf684840794e1737dc00727a79cc8c74c5179d4502597578ced5420d3c837861a6b91f27aa48e0da8c89a462477d2b194c5de6de5716d6426d8fb3392e79c86189e46b039e69952cc619019119382704737a78909c736dabec53ecea3ac765d94f05a80833fa8423ed69ca950e52e6ce4f5b8e4761a23f996a46889bc8aaec28f30033ec748b4a794b8368b93646374483ead7e2abe2fe044737b6e6bb52cb823a9623583e9874c723006e501f123579880ca994a1b06baf8db92bbd61edf3adc68d58bc6c78bfe7c04b4ce0ccff0dd08312cfed6b342941c45e296006dcc51fc6289c92952021289d576c1a0cb48df78d65946af5dc7cef0c32f9b19db08dcfa136ec035ac333b1d19e2a8da92bf0e224c00be2a9352b89322a131c67b0d07e8e335c96d352ccd974b07514fb28e069c896b4a57305597bf56212ac516436d871d8a516f6cee12db913a7a8cac3c6f867e1be485947be9e37c261e7ac59af3147838aa95c1d13176f9a79509df4ff018adc1f4b4320aec06187a66e5a3499911fdd4f9dce38d06b3e3480f189761ec83a38cfb19979dd5726ade920146d4b9e912d594dca9b08866a229af1895080b3260b9afd58017b3e86db8460e6730f11fdc94e0296252fcbbe1cd889a2876b71a3e69c14b70c3383c5e83e8176fab9bf0e6cf7184f68ba50acf70b7e517b1aec0373ebf1cfd864d3ec8b6c0edc331c7303fc324cb2db09c2d4d014e0f25b6fbd9061d7736f764e35eaac845d77490f754b1becc6e80d0615ab019ba5633da3ca802812ec3bb7659c11672439920f11048557ea444212fc7d78022be7ea570b0553c8a33392fa1c338f1d7acd5db80f7707cc16e68dbcd7ca41ee3b5f1cdb9d7e8376931d0b953a46938d984df81c3542f1dadcae3faeb86746944cf90c39efdbb4bcc5959436aec3271e3d875a23925d40027e1bee931686bd19177f3e28843efabc3aa23d05ef2bf827a3dbfefd61695c20b3d01aaccc88d98e947e31766e1007d1a32539d6ca224f7104a89e009223cf0179db919b98990a70e3ac6cf1d3bb880d7ebbb2869115399d8b4f1751035e11a62ca2e9cea3de37c5e0a2a982469edc41e5926e94b1402e21320e1aa4d6359bb0f3cb3054b6116b38b6414a3c26c0a47ff5bb30bc07e030528d4b61b6517009e393957e56a6f3c68593730dc41ba88259dec103fd580123d9a44fffd379b27d38f665c02d733a4c6592498933d471e90302f777383ce3b03c65d12b2c795288bb8b7c1ac8b6b3f24b188793b9bfeb7510d202268d5752be36570f41e53e00e33195581c81fd591e72516eedcb8c2ec33eee3eec2d7ea61ad29842d671c41eba0c9cf27243c537ebb8aa1236cce4a3f101b39b1c36dbc2b0f4d25605247526d598797400af055e86246bc5a52fb7cb6e5ab7343a238c9f0f98415be74ca0dd84027bec0d0f132518d312b5eabaf0a93aebb22d5030176455de90d1beba2c0e6609a83af93fb6336e314f3aaad8f2df25966dd5239ba00ad0b55a6d0b4858dadfa0669b8165ab8ae8e1b897862809c0411dbee84385a3c0ac72e5ce983248fe1f2e6cd4963e5212daf0ff5239d33c347339d764144e114cc0a5afa2213e51ee95484885ef7bad5ccfaf25938d25bb61dbc0d091ddca56da6b38404b2de9a75cc29a753602f4a35d313680e6634de4f6fd590171eac033d6da9d59a59426de0eedc50e5c78bbb21e36fa546399801f7431bbdd9331f07895875011a28eac570be98e80286ec799dc3f77e364ec2e370163a32af0d47ae65df5e2ff3f4a28abd7462c809317f0d5561ac032e395dbef016c5b9dea9aba7ba54ca2cfdd9640417cc653ae1347db275c6a10773a60eb0b25286671941bca84395017ffc340b1fd6a362e04fd85410d66e144c2a9eb98f692a825bf1563c6543c02651f4c27a1f3778668be4664df947f8131ea895a17d5362de34d4ad5b216290bc81a0fe2d88a253c2113b0da8c9a786bade9a0a2ceb571e87ad3d8c05cc0422f94a8afc9bf8744182e1ae1cc5c59810fba2256f630dfdef3ba4da049ea0a105a735c6eea4d9b2a1be7253b03cb8e4f0029e1f89accabe79ac756784a8be457db89780837b022efa16960796bdd0b66d7cfb04d4a16c05e74c8275396b28db80a430fd6df5db17c55ed977f6e4c56011e298c80fdd40192f94b05febbdbfca265186bc4f874900bf3e05f29b8740d4ef42ca4b733a993cfc98a094873f9b81f956aa83f276db3dc173a209d1b1de00694c10be6b00c34cc23ba1f186c14cc6e62b6972fa18edb91ed9c62baee2a698515aad191c66f2e3bbef37080bbb5f2824a72d75fb24ea1c330faa222a27a977c27b7c52739697838aa87ce42c19a3513441c4eda5377ea3a97404666c675195a22e983941ab0fc32b59d3b2e629ba1672085a029125d96f8ddff22c065c745fb3824cb6c0c35c041fe72e6f3b846f52941835959de1cae8aaabbe9e6ee9cf2cb5d21fb9db4d7a522110515fd346678f38a1769500e1fecd633880751ccfbbf3907c3722e375b79c3ea38ada3ed1bc0f496f382be46aaad1c00acf7855a19bc5886f4ead05aaf5332dd76c84e95ae1208e0aefff1d6b60f53e7e11050eae5037a374539e9ceb9d388cf5f3a29aae9fcc31bd9652ff3fffc782f63b817a38a76af464101c6805f1205e836fa683e61de428bb9ba28fbc4e92c8b938f363d0204b2582a488ee91c943da1a36f4bfe0f64bd47e57aaab7977593f120241462a831d74e68f1351c5cc19d6be19c7995808b82fd6b22a5ae27bafb3d868c80b14d841873dab82b6156bac94295edc8eb9de69b2f2cc3e00d63897ed79287603045a5875af5644de683beb5de4961331f84e3f2e6f5a89a1598cacc8204c85a0b0b0f8325bcee384f733d7f3c47859cbbb09ee763504a72860592903553d88258e9ae97b7005029cbe1c5e82a0aad93d4ff412e1476d88374cec77ec62cde04218e3c5fb61097ecb1dd4a8ac1aec3a14fcf823c236740eb4c0c4e99347dd07fc4004624174be4650c7bd8fee911d4e5b12bd23e9336b39cd62f5843310bc0c4bbf6b5ab22cc2f916bc561f1ff5a63ceac2e287bc4e37b2220db5bd3e313715a75db78e2813921b203600be2b3b8858ff1385d76a30c2b8906910fa88ef1594d8e18bd2b0fa96d22736ffccd046a5de0a3db62435428a9b90cab722c6a701b3595b5042f1384cc4dea8903239fe06cd760652cca5368790d7e87308ee68d9bb516effc88998e8690944150202e1fb02f831a934f3c5409e813089d00d06cff55a7c7822fa14ab94bafde84a621d0a93bbd7807a168e63848aedb76bf7124f4ba92895997b9fe2034e344c027a80020fd26489fb56c9221d0a8d62b0ead00f1ef87fd93800bf5fb874dcb1c237f9c1320889671cb926dd4cf19ebdcfba15ab3877304992b7ae1c35855957baa223c9f95205700300c94160df655369253fa2cba72a3132f9e18a7ef77e3d22b743b81414420eed1896c7390d92f86f7b681030e9fa4f4cd0e734eb0c76e2cc38551b04b2354206e7565970da250d974cec3ef9b45d0615c93b936a87592e6a4bcf22b0486fd37213c9b89b883c2717379da15d12d8e6a2f124383cd9cf2de9f015da377f536d7ab802009be263e09b7910e3a2151556fbdadf07296daef052ff5f4590b786ade12eb2e208bcd1069f6bb5f08e69a4aa7e17d412dfce636356ed1a44aa5d44f0d45af7d632024d289411322e2acce6396d5d574c1cf3d5fbbeec61738fee9ac96e674edc4f420075765a89e99356e929a2f2e14320661821b6c8bc8b1bd292a716cf0142f4491200e439386f97263404f4bbe2f90fc8060d9c3e5248540d96a7dc217c2336ee0932179ffe7ac3fadad784ea48d48ee96ba57d32ce33f6e356490e4bf88330dc195c40021af2da18245043a0d3f5a92e35196d0ebed0ad53598e592ffc9f4ea4c0f749fbc0021fd6282b5ad1cd986adbb6f5d1e5afa693869131f6dfc0eb91136866f17fda49600211ea79cb0cc9125a3dc6461d347151bb4810d55e03058f5068a016c12b30badad0021c1ac90ab06bafb3082ae63b2daaf0196cf2fdc2d15a326ed94cfd8052fd454900020de5413a3e7de056efe2025c43fa308679720a88d289f3b939d228d4253210f302166cb655dfaf3a8e7c79c6f5c6f714da506636e19126043f81f8e4b95c2fe12c000212c99e026f390fb5965aae8f0d45df0c401c6783f9cae92e9e1e1813681f6e8a1002028e46d132306551a1d204e12b41cafc8706e0ace85d0b8390779f613787f9013211d105eb2008faf404aa9e962a9c37619004c0791f5b1f4d7617b32f5b4d2bb8b8020c46b255b3ecf14c6522831dff75aae004abbb2988200c34b886fdf062c4b117c205b331715eb92e571880d50ba4258ff288ef243a689c8c1d06fc754369589fd0a211f91fc4111374de049b516a3f2b3d415a37556f3cdbc62333b645625a5afc4b50020f89db562e6af3d8a4c463cd9306bf84e7aeab9bc3d8fe83ca718848af69b69a220a34ab022f0c54864db25e18e286423b084b090889c1f3a310f226e6f5a2a002f20d8e1037b6b56d21e2154397d3b319e8960374b81ca65735e26b03735624f1c04200136844f9d63b0423b4f1f8ae7cd58f04e57991c336a3a4372fd370cffe65f1f20e0329463da31609f6b5348c005f5eee646ab48a4e3198f0e4d6a0ff9aa747d8621e9bf774b3ef8d861a5337555958e42915c8481b95ce82aa5fd3a1bafc19240848021e5856729fe55b65baecb159281bb85eeb51b6ad6036b578dfec1f9c66a44a1a600200b8d30df92c19212d8aaf2fdae76cdd6fff28377d04e0cb15f22d3c17b964ee6214597ee8b0a172e4c566df13a5591848505cc1506430e350ed7e4b96d63b710868020779f200f126ce5c9b130ead12f91c38f8508e79bb569b76d804d4bb13f6a342020ed6966200a58e981f0cb9c44eaec7c81bb78778388b23b3d05bae8cf208acebc20bc9539e953a95df5a6d49c9670629c069e1c9bd5bba49f5a013dd9a2f529297620fb12be7d54fe79c18e206a845db23ce5561648ff3bd4f22be37b398eb2c92ae521f00eaecfef6117afb0c4bb794448e9fb1b9ede57446a341b358d75d840370ec30020029c08a09b0b95aca9fb0063cc9137cd5a741d683f106eff156672c429eec7be20b1e6fa3120d140a97d73d23712751d8453d225f930ce2bb11b5f259f02dd79f7212cc813ba572ad15c698ec9f43f3f08e9fa2f33c493f4461b0dcb437e4cdf1da480211893587d7ef012a0d35e9a4fd9023b84d8f9e8a431cbbdffb0a3c25e7e0da38a002006a50a4eb65307d0f82d1908818a8657cd86b3c999e6f68c47d6e7bc0798cce220e4c2ab28b2a30d97570d91b2f7b0ba6eb45e580d25b8cc2f9b6850f62cbb70fc201078a00d02822e6c76ce26b25db9aeefe9a4df8e1e14eace7dcfb97c99c6503720b49499dba488b582c5ef986f4a2efb262f608aa3d8c0b2aac17bb0f7c7563d1f20a1fc7fd43278f81562985506f0ecee3f90faf854afb891361aa46ad71f8f49e0204db1f93a4862b51d9ca2556193b37a9dfac403324f106963d0f5f84c66fc9f4120c3cec2599069189b34b31f537fbff277638d67582776081519d653cc96f1333f20580e4c50116b3f9e89b23e0130297f24fbcaca32877e74d6f3c5a4240853daf920d43c1565e12ded85af46447ec59ea7048857a324981cfa32237d2eee5bcd2503207c887ac01c9c9c4d0e47dc6224e95a4abbef32bc24a816acdbd45e92a7e6864320b0446638a0f5a7eabc92ffd8b01cccb59becbdc0e8375bf205f80afaa8ac477d212b2bd1399462f11bf5c7ae64009439880bee19e72600f3a96a2fab440b8814c500209e28b37852b88dbe8c6db8d10fed70fbf20ab7a7d5c55e8de0421a22a37f196520eb32e0a06eaf2ed50c35cd3b68929eb00ff27ce62459d9b7d9afadc890e05e432030d2137ed0381939574e10600ea03ce9f95a04a5275ff390bbe3e15c970ca63f212dd4974b615ef27895b2ec4b822c3cd5ce6afbdd53ab3bed4c012388c21c0bc200206ae0b022186c365c621cb0c0e2f5069953860a649e1cbc808af7d7932e48a96e21a682f1c1edfeb0af9798574d137e7413f31e8d83be520d2f554ae82763e115920021bbc91a7131bfa51681f8a9f9b8ebad65f7c4a6de4312586d3b5cbbca5a1e15a88020a42081eee2e36d9b935c5884f971c6b98f1663179e5c7ef3c6e8c26e6678b259212de5321c8f939ada7eef22ed0636ffb6972b3e307cefd98b661c781ed207c3958020e4db2bcff0414ace308aa9708764adc5c1793ada618d15eea46c21f25f825596210975a6fe381897961153cafb7caac94e1dcff791f60c059668cc2f21965c598900201ccb0a6ca0d5ecec764f0a068eaaa66be3366f5fc1010dc00604e248580690982092d793d85382fa76fe487f43b000e48300242dde9a501eb92a2df631513c0a9821abfec0f2cd55f9c00f2d826a7c76b92131df664bcc8b0c370589642a404fccc30020eb95c488d4f14fb2d58fcb0175dea295ba4157a3fef3dccbe95af71455e34e402020fad6e577284c9200450a4708632a602b3c7d5a92bd3653b49a63150089383020a688a54faaf06261474df6d721f2121db53856b5b52531fd387c24ac306771f521e1391c525a5529a1676f0b2b65b3ecc46a7786f0173729e537e3b2d1cc33bf840020badebc8ebd9f9b5202ae1785a7afbabdf2843b9cc8a46a98945bebf006f7540b203c6418fc2b4c41f2add457cf4907d87b1ab3a6fbdcdf4a483b8e03ba3c8f45b520b4f7d1253f855b618a5aefcdeb46a7e28ea84f6d1ec98b6169c52d57ad79c9b32119cb9d0a17bcb9d3fa5043c15f74bc44480d6110073c6e5bb282e9653d6597a680212b1246df660c932a72112355155aabe5c7e0fd2f8082b52aa0bc03049589cf9a8021d102d7b5d63e570ea35afb5672cc308218860ba85abd9b0c3579c150c21a06920050fd00014d9e2e1eef14b339b8681ed6439a758aa67efe536af112d5331eacd623a54c7660133745aec997cf2fd1d19613e3602aa703265c4e5f0f2210d42b7c82ed74b683d1c413d908c78a2f6c4d487a72347448f5a5fdd6d2205638437ab65ab2a41d51e4733a4863d1510d4e214cc3f4e55e12b20bc258fff0462baec5c0228d6524972a5961d80b7ab8e410093f55c5f1c023482be175c5e4241c068c8fec352b6e2f4acdc3d9d42e814c82777e190485b807cebada5a9dd9b6fec986866721f3d5917226e4c058b52f2173a1153822abed0efcf6148a0291f1b01f746336500d33b78ca6184a0c42a550457d216aaec77d104bf1e2742becddebec7cb9a9ceaa898177659e30c0dc7e992e0e923e283a4a24c86a232992c556c3b91c66befb22b96a851a0718af97cb1ae600fd4802f035d8ca03a5339394c4fca9c5d326173176167e56bc7c350c2110a5381b5aeda45c7f57e1d6c0535d55046758befd9f68e68b974ab53bc8756b9f5a1974844fff2e0a5882239708660dad3230b47b96652ab700fd00016e6b47a054a957b2ee7ad64b94ba61c09e8c130b227a0b92ddf2c466099a01a0281d71a769394e184eecd99eb505efe0f83f30ca7c161dd6c553df794c655eb3a6aad3efcb180266fb28c5697154a74c177140209084e2d5b80cf32179fe8dcccd5abc410cc60be6ec4974ca86d6e75ff7c67a3d75c39e234e79bfd32b595fbc0eb0dbbb6c03ebdc363a2c82582275085feee50da68ffd835b2cb6d2d24a8c781db75dfe1bf20c9dc9c253b35a96d477955bb55dae3cdd08fdbe5efc77012f00ebf45c503bc57bd768181d09dce224debb134092b8e42b41afa1f4ec7db42bfdc032a80270d47c0d86a7bcd8e4c6ec1710bf31d6600dc4931207015c67f30badfd00016e3b71dea103a134a310098adc20e6197a17af69cb16d64decbe73c24afcfa7d0b301cf26c8af14c234bfb9d9327ea7f9cc033d9e45794e0f7d9f4e83749cb6aa6bc7021e41b84f0a12ce414764580be4ed9d91098e0bdf16049c989dd50c82ab47051ccb3834089eb976a87bbd775b28eefc1d0bfdb350d65233eddb5b658d859c02f88002993fb9da5cd7ad819c61bb4a93ad191b87b1654dfbb028c1d5bbc07e3f3b038ac9b7c16048be3602fb19ce32421345f414cf7cc119ab5da5499c0e22e3959160094a1c82fe9d8197467e26984c2422ec8a607a36aaac00b5bab67b7aae9b962db7d6cb810e0e30218347b21f0ddedbd2b819a04cdce323b2098a280925b63f9a4a1ff3e72874a407725bcfb058aed121418586e484ed96e67164e32eae737d724e5d98c0a1cdb3f1dec54c40a602ed7ae1564532f2e2f09316927c44397fb3e78bab7d218907b0d6436401a8424544a54e706a548e2ae6236143d30c05f57cc18699dcb77bb085c21e829c840236faca57879cbc7915332281d37608083fad2575038068e23d02d130b7cc2a127574e6d1e03084d980496df3e656ea7c39b3ab51bb9372cfe004f90c8aa7df9fdf765a3aa92a61ac1aa4bafa9428769d687f138402c75019ad7d2d1acc2eaa56d87096812e3b319f48feaf3c4adc55a5505f227e85220d7ce6d1a3bd6fde09b6b9b8903a4102c938e7577fcf7d4c711807c3f25d27721ac2e50fd5859eecb1494366b108afedf9a54423598a4f6f4bfa0fc81a5797647101e49c2bb4e98047ecd1244ca037067b157243e1fd4d752d6ebee5dc04a516406efc6ccdf1e25e64e169246ef559ed1aedfa4ac3df043af12d0cb957d41cbf218f18e1b3d7e09845c5b7692ec99cae70bbe5ddcc72ccb1d0c1cfd0001f2edff009aa96bec488c5499d8201f095feee3183f48aaa7ee4856d36631bd6d8eb6e40cb8d626ba62ee826557b7852c7a5a5b8bf7bea65e6205beb3bc1064d08d8a8c3a992f59d644a27b2b6189033a6eced5d80a93b7c95598d6f1bbb7da9c8ca7fa0e55249e006221536036488bf992b9aa09f269ef4b63fd342a24dfc35d0edd7de481e9fc342e2e537ba468bc284fcc79bab80ef75585520900c7c66d4f85f5d7f9c7319b8bb032da080cbd28f48514e71b85695bfd2a03f316d3824dfc1709ef0a78e00c7158a22a1267ae1e4451f1d6281e22973aa8ad9b59e5e17c030de79885d54525d78539d5f0a17b9c26fb249ab85cd1fea37260312986b3ba8580e53d0081992754406feac0c992b56b9f3321a4f20c9ecfab99ea34c7cfcafde4d44b43e4d73270a62ae18d07ebc0c548df0b2abdc6efb55e1805f6c5afefb73381d3d303614810873830b8c57e66247d2338c1e3334b74e12e0fa68de1a33d9fc93fdab9d94a4b0d7b40e8920cf0c91915fcdf12b9e4f7f5c4436177a037357b81d2a06368f3fe7595c6350af9997f32b1545013ef7972fa22b841ec13fb857c6586c6f5da64e848fc32c3f3f9cf6a9f023264340a0d84c974ba2b9800255c4af804abca3776ce4b02a4dedaee53268eb1b6cffcbdd22d5259fc1a464ea01c7137e7096bfedeed96f892509aca7354cced2b0e244a92687f3751e30f8a1738869100fd0001c90cbbd7af5436569507a88daf320a680a4d6fe1e922ec3620459585defb1b1e222c52ad4ee41b660af86eeccf53a7a535cf012b0928e911ff4708876f23a940b691d1326019536bd04e01f629817a6c58af5a6caff5d4b5d95ae5bf4b7820b70905005a34829cfd7f848af1e8aec7a45f9520021e7186e63982763911e3ca2b295117d047cd24bcf2dc5b03f64172982de0d58e255d8b4e981d5d72abf39cc0e1db971375bcc73fa601b4955756785fc5d3ee040c216195ef4b99c301c573a0505194b7a03df2e21507c0faab4d1f6be181f0a4af3280e914e5480cd409da0d0558769d3f2aa701d63e97bd0787d69995f7dcfe3f50f819d9a5283f8e59bc80fd0001169f23dbd1f3fedfb6e7e66065dd9862dc7188aaeaf1a959a5beed3522a4868f54fc0f930eec31af2ec85cb0ca764c07955394d389058b0953a66255ae1542b20c7dc7e2f65bb23648ef707fc8c265de68087bb866c94a0d35124983ff0b28b6a8c8d573ecf471e102e7ac8a56151baae9c146115fa72ecc8b7925b0d5d8b53057f3244298446d357738c26e1819ff21a87174b22ea30b9e1db4b8ebc8f337acb77c889d1ae4c9f20876cc9452b397ffd8db132ef6ac7ce3f30eefc4ed5b3da430c965bf05a2d9f6f0dc5554802f81ff56eaec71399a1786543698a6d882c51a3c777d0fd2da5cf6a205e99d8928ac7a493fc24aadc76bab259bd2f5e5818886fd00011adc8456ac1050112b19698f9baba40d0c83eb2ef2291d55568773f1cbc9814696a7f77da7a21312332eccffe30d928a225f69de1523e08dce4e266e8e172292b67ef9adf61364e92dbf048a9abbf10e7b912a773da508fe4157035adac98c2b34b52f721c18aaac53e7adc54ba786dbe7b1e2333cf2657ffdb160f9bb35b354e1d195e73e3673906cc84d055adc7172bbc4015ff4ded99752f8da1d47690e36fe143c574797c26366991536c611d19576960ba716fcde5fedb1272ecf679cda1328fab4aa823a10d4d8d68a566514d90f76372e6f8bf73814813b391981cab9c48ac091a9c2f8cab593d87b1b3eba59a497b6ef74de2d7995ee7bd39edba189808362331ef94d8c993c6b0f93a64ec85cf0121d75ab15df35cf9cbfe384869b1da4b26da35ec8b708283034c727e1cb766e54175d3b0272a27cee00ffe82c0ee8d635765368f80fac407477c458eda70f62b2eabb4a99a33713121e08407f64bd5c73fa79cd8c5ab50ac9d8a8b2c2e8472c7db20f69d5a2014051e0003b28905880e52e0d6b2935a138d9698f04f949feaf8d54033fa156e76e99ec8687e6450504458589e34cee6bedde35e8ed601f40b10fc608aac85ef6f24e65ffde649b2cfa15e1ff500146020d1a32c0909065378b9b70895bc0a790b76087b83951e9e14d8d8dfd8950275c1b9a2a9ca0dcab5f8b117f516df08871e83d7275220d9ed55980fe51520baab8997422667729cb86367f19b3996ce0b0cb59f92f3ad96a0186e5b03f9f87627e0a83f8393b545cc395749b52df3eb54093f4bb2cec5a6a741b0163ed25594089a16a181e0a581d5d9f83d71f04c0a2c35bbd3a4be4bd2a71678ec6083cdae4dd60ac92d8c27decd2427b174cd97796a12bea4481808569cc4d1f8185857bc550689c1547b2404e380e27e127021c945e86f739017d56c6d237b53bad6639ed45f275ef0867a8aef290b42c33e1962f96dacc343bfe7450850affa8bc31693c170faf56fe3d4403e0b2c8d8db8bc2c05609004a2314f1653899e22e42737d103b3c0132dcc0961b609f2af1894bb7cd51d128bd956d720a3ae5019600805bdcbae7d3e1119b618e05853f5e9526b7792bd08c41e15fa3d3a415b0fd4d58da7bada229c50b10a2891b0328493e83367c29ba58ba40b71dadb5d07abb33496c4801e4482d84c349d2462e2aba0c141ae59d690a8d3b2b563743553c7726659af41b3b5447fbaf26b79a0b444a6c7e9cce04dc84b58073389a5667e9510a6c8089a1c4d50f60221f2743d34012878f0a48f9c6eb7519d72f458098bcd314401ddd3163a276a405bdd02111de19a46e194fa0cb394b0ce00cae99281e1e6083efcbce4b837cfa03bf02ee92e565ed13db22cda2bbe41c9ecf2265c70ed7dd37657a93f165f774c343554f20a9512df3e3f21cadb7889fd412951f1cf2bf96377a80a54e9212b9cea76fd149e075d5adac7f9ed01670bfbc1a990454eb902df90a0995dfad2bb7b040203b867a5292a92f88b5d9f58aa0ce8e334c990cf9e0a3ae38ddfb3501abe53d3a39e9baf3d039779266273523141925d67f5aba6b8c5fbadbc08a0f8a570eae4e0f8b3d8632c6e32747ef955c2995f763ade8b12a21b1cc7580425a2a52cbd7e0ffbc714b2d983db7a2d44345a932f1c94f209d814ded79b5c1d09a135199d0fc5472d3483284e01b609416600c76d498c284f93a8579a66d5a55ff0ccd9df174f33e27333298381b46f02865e2709b701460ca0ca328710589243afc2058c2d1eb0de88991dd67c9a7523826bd5de0869c6de9787d99134514818c65364dbb71df8b1c58400bca50760a7c32a0f8e5f5078b6d563c30f37ff5e4a7553e34eedff9e0a3fc9fbd86021adf357558a41fcf6cc4b0f2c78bb7efb2b3e2530c7051c7d77ebe7f77f99be5ed873818654c821a564a6de79ee844323b2e7bbb0524846e2cbf271a18e54df1921bd6475228dd9b66c8bc4f71d522ff7b8e00fd0001ec5648dbf7f08eb3d67504e9a476030ad225e7eb2b6e8f58fe4039e5d100a089365eaba7c4b7d81141f957e1b2456c3f6c1a9ca431518dbcfd4daca717ca15fd9bb6909d6784967db2ca9425037a8648b4b7a34e7e6d2c167a4ebd25d348075309b92afd277d272f1f1548f17fdc95b700c710a7a65d15b92fc1a4fa73409af8aa1de746cb92f760ddc9b0707b14c889807999bcbc95751911284f4791a17e351269050d912d9fdb2acad78328ecf7d983a5d06b7744cd83bd7157a1dc3713b430d8b71524892250e3dc65802e1e935f2d46752369bef60a1937877338b6d568491d1d47baa031c44ae910320f8082391686321724dbfeecccbce809f3db499c80cb4c64cc74f930192b00fddce8c7a81cbe7a355b8626e17a9557b31b8a16c1bce6263efa10dce7224438644edaeb74e7d703b8db51b3661cb091039a9fa80fa595ce53249e4385c3fb2dcda24f4376b426b44fb3b6c05222adf9e7255d49b2c242725c3cf029418d49160f965cea6c5c83b616061e7bc343ff5eda6b3316c842fd0001b76fe95f498b6f85ba5dd1a86ecbc0c0531657638786b5d60303141fc1b416e6bc603a435ba6bc310d84ca065dd1fff0c50fc2e27c72a58f586258f8ca27c6273eaa54b9e18200e2a52b4b95a8411422ce18d68b89ffdb08a16762d166a2084c273a86bef650b907a56c0872b063ace5faf3f4b8cb5ebb03b3e247564c10b98675407070100336dd9a956591a820564bc75d7ebac46cb9b26456f4bd637e3392b33acc7693d12dd5e51a59c3896deaa4c20394e38e2b7b83f7c0d35e01a0ceafba78476e98ceb7ca36fd41b35bda0cfc70a53843463e8f6bb29bc4748510ca0c4af248f80406ee36813dd13801b3cc3f7ccfbeabe9defd5f82d8f3579e9afaad809d6277ab0caf24ef792234d3fcf5c67943278255cbc4a62df6ca1d28f9634891d3576de7ae6173913560a4ef961b8f37bc541a64ea357ab5e6e8903dcfea8042f3ee5fc7e02f36a9ef716f1793129f8da083634c3a5f0de35fd799d31e440f8691744eddb4a811778784ddf90a1071f7713ba9da90eb7169056c3d8788dd130ffd000148c3ea60f8a2b966a1aa7c4a9c45680a7b0da7de8b1175020089771d0c964d05b887a02b6a5ad3bcbda1fb9aabfc0f15e6b80abefb93667a0bf386ced95f299b34638a3acae63c9cf27b8e0bc06f846ebb6ec2d0bd441efcf552cf75f4fe7abef8ee08f3abacbcb5ddb5ce3c66afd6f5c6e2af342e2338ff3f872455e47a5aecf7554b76ad0cc061ee43c1e0a1c04dd59a1c6971076072b031ceb952ab70ef61bf628a0dc74a294b1482dfc33f3fa8ca57478bb45c3b27d0989e9e7af3012b151e5c635d2de98fcc7abf439bb2d1c9648c0f7a16d710bfec70b29d4c19614f2753b1e9f8e40fa60162d3f0c958b67778423a4442ec0eb4ba61d17175e833e49481bcde31fe77dd2f2608fd06d0db8443ff7f67054ad4100f3070ca65372abd58cccf9293c69f7df71d48ac567c56f609c58c0c0f4afb43faeadd44129ef175a9a6fd508be6c247590450c4e1620a2501dfed3ee12e517c48f4991a82d468dc882f9e41152eb20cf513bba8f501e561849c89995c173182476d84dd29c86fdcf48a0080ea230c4edd7f5fbef149299e4d879a7ea27afe383993911ddc05f8ea5f06829248e2156855336dcce1bdf4d38b7700753f38ae9b9ede837138cafb10225b79d381c2976c0e88084a6cc48aeca66f8afc9fe6bf16e3f12afcf61d15d5e4dcf70f5339ca6b83a46c24941d410e55cbc384729a54b1c77cc5ebe705763ea6063061fd0001e43b668b855518b99df3be7f69ca00bb33b4eb667a7e8057af87c034b8616dc3e6eb0db5cebe16dde302619e2ee68ad6fce61510df492bc623f68318a757c5ed54d26fda17ddb2d1768dc15cd4f693d3fc46f38623262f061f0831f8b43a33d65d14a2eff01fc042d37828945c2a6b974b70af255095a3feafb05a3203e68a4809b574fbb7843655d495959f1b3ebd85c4ef3832437758fad5bc4c18b7e4e288860930d533f2ef8745bfa5d6890510e1d94b8a04f731d49cbefb1758a8ca8b4f404f7c91e4f8cd21ba392cd09cd471a2784fbc63150466f3ec3cda4b91709d13294d09e58f653755a631d9b69637f3ee6b89d745b204d7bca5d8319607f049a2fd00014c559def140afe973b13b8c019c435608863d3444334585b9a15c3bdeb2a193e1c4138bb2078b0ed52d5cd3a1d5cdc24de762f8be18264ed0df279ce3f8fb6f61f8c3bffb2f4f3ea756d32cc23f4d13149fd9ad449d827b1750f86f4e7430b975756bc5f40a90f07ed3f24f06bec66faf4f0b11544ffd45891330047084c3207d15c42b9cbbfb7db02dc8dd4c2bec47785103de7b1d8dc5469022c8508d5428630074bd9806f654a4de594b40e359adf57f320afd07cd65211423bfd30499e26343c09261eedfab7bf99cb2d6c11f220c8a6835a5af84c61ad88c17bba8c704c62ded4ca23affe6e21b42b3952733fb6e8e0a9e50ff5486d798595fcb9a91495fd00016e881411dae9d25ee76a5e384b03b50fe1c5dbe8bad1afe9a0e0eebe94f993630ffbec326c65a9967eaa915b0b781c006ac47f31cb1943f439752a742b3f099df7ee02b3fbdef0e29fa50b5d9dc14fe4ab242d3b493478a347669ff5e3da87c01b66ac941e617c0083d7875fb57c5ac8d29c5e8729c9d16e172eb2bd20e0d50e48d92e1ca231f3e131aa247afa47420a718363a60ea9a1e0fbeef0f173232fb26d85ee569f1cd5200457c0be8a29ee0dfe87132d7f6e131158edbc6794d0b7b37cbc3f0621bb7775aac3ca3c6de3515c6d851f2edc64506506a42c18d87f05e1a7073fda15296988a0bc3d933c7f000123f0b8104c32cc49a20fa620a50dd79c818450a170052e9d9bdd732f941b61dce08c595b1bc1d93e3eda518f15004c77237c1e67bb0b30dd86a1e47b92036837970fa454e3a7a290b4ffe51879594936e4e63442169b7d052f6a984de5fac093f73537109dfd9ea702996f74da385f60ad5f8f6dd4b712e5d0e268bc1a12508164d9d3f4627d53c28eb4c85efdfbc2d08700fd000161f9d86d27460d8994c636ae18f6b8333f821af60cd56f0475cbc70fd69b80869e1233ea67156941142f2e43fedbd76ab58106f5c86d630a63d213e9f6fb60e37a26d39130c4ac2b888837ac9cb6886daa990c1837df6996944a836603fd22c83d0aa52d0aac64be9454a1c5616f2754da2d848fda89c4aa51678ce3259a16929efe3c105fc71524317014765cf655da293437c52f110d16bee2a006752196ccde788070777fddbc6e0e20be02f0048f9770cdfc3a0cd79d423dbf08670fbca73f4456f425ad7b8e6af90a83c074eb8e8766339fc510fec697cd0de7771a1516f570ed21b3d7da18670b6d60bb84f98ee7f0c3fa078b32d6b87f2b5015d36dabfd000184c7cd8fc591133ead3c3422755a21ba56301bb92f735b790b83b235ccd0faa338ac30c60ab2a4720edd516c90aa7fb6f76214cca35cc5a2803591219046d0fe8d50decc2c516b3d3d4c52db360435d238e22916b58c4da66f3782200789bb1503e4dc679044d47cfd049be948b38171b901db128e41662b608be18ff70d5b67d0e89891dc494f583ce7e8c119c05828601e9d7e8f8d0b7f582af89e02bd6e4b6a97e521b8dd37bc7f7fdfd5902a5c32e5c544f920aa0b05280885ee0e4d15770ad2e1d6b1048e2a8b2080f909eb447d39c354473fc21b53ab03db4ca983e09a30724807969ef63e89df0c95c4f26eab92ae980f531322494049be387bcb929f80427a4f916b11dcfa9c04eb04147df6f6b7e2ecd0498ee1759388a143b64d68489573b4de03a9116e447328341475881d1e982d1b4a73c88c9fc445d2bb56dd4a25968ec6a3cec4cb83391665a513789edf412b841b4e78e2c27ce8a1d88095d9fe969d19ba4729d755cc85056bae6c30576823cb86216989a93322d9fa41383ffd00010781d2459b9c8cf38730baa7b8ac9927e41dff284cb9b1b0504985c14cbd4ad372846c5f963534ef3f34e3e2831ca2da4d9487f03f3abcdf95175f3bfa8baf997207cb572564d5fb897c7e8ad3ebf9cd5b8081618ae18973d3ecde6ba374bbf73a3d2b775c5d6686339137bd89aba5c4186d32857f951f3c7563975390a62a3f8152601f72a9673e0832afa4010a014dafb48e02d33dbae18babbead0a7808aaf01429c6bb32dd494cadfd5481a5ad2e7e260fdd2eff34c29b716ae4fd4453f040a38ba215051080a9ba22ff7eb7cfaebd573bb5a2b6ba50702998edc7ddf70a94c8a9312ffb0ba350786cb30d3366fcb6c73fb1f3d7f2a397664331a511eb8b819931e2e644b3546b1c4f64e23fe2581ecd377297ed2e20c1b107511cbd4884b26290bf2ef4f215e02a0ca06e070b042d5aafb942e2e2cc4f5c9a7416ea26209a0fcdfbaf08869ad67bc7ac01e8c00ccaf456e64ee346aa6d4195a7be075955e4d0525281bc445df3b59f1c0d9275f25d44ebffb5817bdf3ad23ea203d446f3b500fd00019f6f1fd6bdc03d72830399f29b42667b013349d66ff11fae467d49c2c81897dad24b3908bb60117ac0d32fc7caebe635a840aa63021fec96c000d28cd062914710b66756ee6b87f0e2e121bf74e5aeb29be237fc27ea5206c59ab84fe0e26e6b2f970d0989695a071596ad54939c305cb711a82cf289512f6f62de7565259779da87ec8e8f604f408695bd2250930732ce864cb4260e8e06a61e9fda4f2a5229b321aae6bbec50b8f93bcfd256210426a575e9bc47f3f33281f921cf5c726f4fc5a686a6d69e0f1bc07adc3106f05b4416253475cd2f9dbe37e9aaab9a288bd34671bb085f7f3f94a5580639796f9d72e7863e4f548e729c6c5a986ac779f4b6807c0b5835c71427a85890599660201de58acc3a350657cbe4e8d55b092b69eaab62a896ab95bcf17d98ac118e1616670d3b1c7c3bae226d50d5160fb75831ebe80c8ff42ea11f9b9a4cad9c4db60dca9fc7b4225e6441a4e6aa1b97c99f410af5bfac687b370e80dd3a496b4c46f25b83ed2f812d5d834ced2415a066fc3c4920fd0001d88b037431defed93679fa2adbadc0d523be7c1d1f4123dffcadec9a35ce46023fd69b69c121f66e60888d4d17db3d2f1cacab533fd45a7ae4d4e6fe10edf28ad608a8036e46b28c466b8b1bc0a114a81a52dcfd37a71d6c3c34cf2c3ab5bc6c2f8702d5b2f7f52cff728c677794a51c821fa9fbd036a83d4e68727a942da0469d5dff38b37d1dc0040985973657e8fd393439658a55c017fd7cd9cfb2526e46e8e0e1d595ebe28ee83ff07c406a81e52aa7b056d896d5a121a43981b4a4b250cf6c59e6dcf5c7c045345db5ecacfaa4776b04a226d39eafebf7ab613a6fb0d6d2171a9fa1a23d2dcb4e52af9bd55e98ca45dec716845ba0d81e823a65e60cb2fd0001832ac6f72939207f4d61370b5084fd19aee8580760e4c4c711ae74955574fe25d1563016bcd7f478a1a0449c0c7b8d4f14a50e68fe44884c02af491e9ac6c712c270c0dd89ce40105820705b0a76b0b60c39d56693104d532f29b488ce5b4a33480542c3d0c9b8b3205738a46e87e472182fd78b53e21878d6c058c3ba5e7b96cb29c2fc77070ffdae85136a559131eeec9400127bb90f99d5075e016af5f94a2badf083d4170e474027789384604a3fb3221a7104be1618d518b73464c01722a3ce8ed6036e7eff7425dddc6cc464f7b9f2a15a0359e7db7e765939c69abdb7ac5d64841a64f60f26ac73f62eec2ac09a03e1e922ff4b81ddd4ae8d85a5c482fd000135dee159a980750f3304012293862eb0d859fef525ecb578f74e6a1095aebda65201e12071a87e02aa14734b3e97a39c83db568d2d084381725bcb272b2f11f4b9adaeb76c546963132b3b1b569ed07445c03a1e1e8078c8dce70ebd150f7d0e9dec90e0a0a46c88de1f2a5f85836aca73c0cd3513f5d8ee23433548df2b1a544aebc56f5b16800c75b95e98068f64acb88af548430c216842a026ce450734523d828552c48a62d5bef3e6de78a2f363843ee2197d52f9f4ccd63236a7f32d0e210f543e62dfed9c57817e6480fbcefe455883f00a54bbe5016650d8ca93319105cf2effc67850e7b3a819738d4c41f724bf60e7b0b3593b28e2280b502847878104f4b21b57ccb3d462c2235d974693e15fd7e3aefa6d568ad1e94e3de46fd4dc7a93bae3f3b6961c6e53ee4570217d2300335132ff7fb7246fe7856a4cdcda1468375a3cfc2974de437da3d36e93c4e2ef8d6eadf3f8b5a670adb2619f01191df0d38857aa5052aeb0c5bea58b19e926aa3cbec5ec97c1be2482cde13fe76ca900fd0001e12ef340f841ac3f66f3221bcbcb2bc195c53926046fa247410ee5ce8807ef6e5584dcef3888f8858da0e9ffbdc339d0028e5d398f34f70030d3cf99052bdb80d0c0897f436941f6b2db13cf5c8495c6f778775efc8147015de199a864e2df96db9e10d74686fa816b47680f0200042b5630158eaeefa323802ac0faba8d13cac9be2c5cdf49d70a1c516afc1f665eef558db4769834e756022dc586a2fe1cac81062a2c15582ccd1b7271d87f65962884e03aed5ddcf7e143ec9ce34c65d92d4bb52e82296dee3e06e2e8b50bea3660fab3dfe89194daf410e92568b9cb6ff057839be03e9f4149b8830a7ec915745a13a3ae69d20ab71d49038837b894fa91fd0001e5d9468d7351373fa9ba26f4312cb5544e9df2c4fef839cf14e9e9f84a897271f7d80700643bbebc59c7fa17b03a42f64c960e8ab1e868229a1d27a57872004dc96f746a488bc9847babc6b15b67937831e996fb4fd780ee4b265749f196b63c3878e339050e7560a1b64d604f04046c745f9453e11f53547e031961a40391bc80b91d4b5c0a4b5f3258be2414ddd0cf4fff982e7387b6b61f67050549ad7df85de5c8b3c984031973ad27b2450d82073aecf63c69370c463c7c7cdf88d0009b36199e12515c963daf51c0df4b133e69ba684da21fa2140b1ac7549694fc0c08d8fc3cdcf6e27fc8d446ba8456d5d37dc41839088653c624df4e30cc3ebac08b80f190d0f80f1c4dc4dda45246d68880d50c79135c21c09e66fbfb4f8319f68136f573efdf73af7a21fc5fd956eaaa493ac19d0d8a7da804c6e4a45204a423d24aa10cf44f429cdafa99f263090f030201a4b6b939802b396c78247606b36e6b8e7e3995259d33170f16d7df0387a4ac281191a9747d7de913361bae06195cbe1f816dd808c819aecb3cc00107d39443c7093eb71d3fccb15e01d1b131a1ff234dfb6fa89a4dde24369699faeb94c960f2a31eb5ec1a4576fa527d2538bfb11d71ed9094efa588dde80aedafd37c088c45feac59e3b3952d3635ac1df775134e32ac32cbdac20f42615b085485a09f3278b560ec388c73fee5f34ad717b0f764a0cf00fd00019f49b6ff97b352c8bfc8aa721ace7a4e56601eb8e1c1abb4edbb7616e77be93b081d07ad6920e1b229d475d6b75c0abe489109ac0b180f3bc778154e8e7e0601e0114cc273a92acaf14380ea40864be1b9ae754e1d68f0ce94b862a3ded9f9575abe75860d93e1b392f3b8a62b77fcc9219a2f56f53a7a6d18e5b3f120b3ca6851fb2851e2ead9f986e173565bb69efdbf20bc3e18e0684be7c631f554bb77918b1c79881f31c5f3df801d817f8dd7573eec91be131c2d8f30a020dd1b7c83fddbeda34a4fe5c9e44d2bfd7b2b740f8deea0af272fedc6f72830780d2c60c1f78a5a529b67ec882f8df0d6fd37726603b9730f0f719d838eba835fff4c64d38d81c66fbd40525638114694ede9880845318f00ed2b517ef1ce0a2d5247d9665e0cf6c2a1356e29d6f91a9956654b7e7496bf173d19701ec31c650480e48e460f68e0cd8b5df18c8e0b827a8dc9b855211edac29fe851ec8ba3401cf12e02e90b3c094cc93eee23d48ce575be501bdc8b7897986305210d6535c23b274bb53220be00805d789fe8119a84e6c4a9e314455209cd708b2cc2068723067b9bedb5c5cab97c2b24bae587b3f6f376abf8e6be6ea07ffeb8db8b5b48620340ca97f84e18a15a0f1a1d9c03c03e66bce31a05aa7b512e29c86b83d9d4525c46a99eccd206528a5913dd8accc9452f09cd74233f8a9fa9021926700847b85bb41b77c78d1e6303fd0001981a5f226e490ee21847f44782f8eec25b86cf0e9e85fcf8d2aefca4f954a014234e06ccc41b27852fdb202faeae570314f60c1fd43aee197cf166069b9bd005e63cb368312e65b1f7ce53361d1397eb47242557ad1e8c505433a7c8e01807931e9b870e5407134d98ad88925ddae33ab59de7e690dd639d8a423e34ea08ea8471e3a5e6cf60ba3dd51cafd615e9dcd6065c76fb765c43fad7f6b6595d9bc8fecc5204fc98a2e69aa32be2bff4dc439ae5eba9e753bb6779b28cdf95ce186f20e12ad8448306f3abbcebfd2900d739f3e5010eba9c96de9da50635e3c7ea2dfe61f56b07d1992012de23059c52933be681f6899ec0ef9fa93b108f62333917a6fd00010deb6583c405236727fc38a4ba56f2bada8486f9459e506325172f491ebbc258a2d48369b11d647ca750a63f76c95fb7f63c0b41f9cd992de0d893f73355d7c66ad6a06316124360ab58b4659962ff3647a1c4cf80d01e4aff64a7ef10b7d66f87fe8c4a5d6d87b3181917dd255dc954707c73a9c53d8396ff53b5d46fceb0222495153e694a751d63087d3d26645d451e1b296e797c890480c8137a70dd41afb3b39e71f3758d0be5cd1a470753592fdcb5f7565788b0000f76429ff386028254bd8376e09019cad508a8c69aeeb133077faceb3f28359162eb76f599b3a747678a383eef9922c45fe0cacc39b6b23b096a2c5061fbded03d5172b0dbb328ab81fb07676b5793c7290cf171cb4fdd0e0b062e96830c8a8b035e1b4af7b2c3ebf5b17552375c6babf94e5c1d19372e05da6df57b8ab49619c923aea7ab3b45bcb33645786293d04263c1894c01f7d2a9c77f3b89ee5b308a70b180085af2c80e923f36a41dc65dd676dad919c694dbdba54b3af30d1f978783f72e5b7cc31145ac0081a0189ec7f169e7c738703fd014add64dd91f3a22c0d1b1ba6437c61559f1032338fad49fb6a6ba34a59a2ee9312046efb513afc35efdc24e7100c1f3d9810dc9066929643100e1826941f473b16ffab2595dab23698124f0005518a69af1ef7f31d878f4f863028fe012eebc71e4abe0272b7a2b41271090e52629f017f10ba50080bd627b49644aee9a05745c8abf1ca4a679c498030210355fb4d1b153f719ef9b292de99e3b2de99e1e8f8a17c7a7d51de558fd5f424244a07fcece7ff51c49d404b5f79c3cbf697c1ac24fcfff0cf8b8e7a177949932cf2a38a57c801f8d9fc750da2705e1bd1e78b37f78bdfd3b23b674f5a27ae98875758dd32f69c6c4697a81b05787068b9b0e1b5bbec3c0f1860beb4df8d881753e42adfb4d60f9e387a2fa49c8acba11792b5098e42ca4142201e835690b5f3147a47d72e84bbb6ef924e3ac9819ed95288c51afe83b58414e1ed651050a392204cb496aebb9400d2a44be6e49af5fe4984e6ac28be25bf9bb4bae36c9d3740198333d177ffd2afe7ebac50081e3b6b2ee2c1a92d3239ff153aa2604e6e9c1f3b42d9e97bebe3c3f8b0c6cb3c0b1d116a2ccdda92f2575adaef4a9c51ebc31fd99c97d1894a9f9cdcdee2cc6959a057efe65e63b252a580d6a2b8da7da416a9879f6474604fa16445f79dbf1d09109384d56754a4b32eb6235a8369a98c775d2a275a98d7fb64f1b491c5c16be00801fd88a7de2fccb9e2c4863fdbbc88afc8c0879002a40f240195b1cdce886b2cb61281acda96932ab20a1397e3064e9b709059db0fc32c60f9b56e9b526f38cc819952bc5fc2d640963f8147f10fe0507c79f2f824429dda340249ec2d59fed35f7417af892e177b15757ae992ed92bb06947c6fbd4caa8300da84a546ca28e17817b43d4250fe48a6e45ae69c1a226415746c5797dd7a27ea1101767be5814f59e35af0402cb5d34143a34ab5b5efae0b40b53c3bbbe5781aa116c291147d39210365698fc7308451a73ed885000576601f66d4bd30c2d5fabc7dd69e25fdcfc9883c0dd0db71a54d0d443ba0ec6b47255f9de75560d0f5199fc6289ee9114c9a300804abfbce95b158ccfb6d86cebbbe2d0dc4155ec116f6e6630bcb6e1d1eb303be8abda809ea490e033e8c28b3c84f85e0a866a48b9434cfe0780b48a6b04036cedca1bff30f8d16ae383144590bfdc37bd99303a9a8cdb7b758b8b6f27fa7b5b71e0f73800ca9c64db1d1c161daa539bac3f577d3072ea7d1d16e56bd801a3e27f7fba6c255e16e71207ccba48a087f8e9b24cd712a1c2ba6f6efca6e4ad528c4f8ecc129dc0d2988a956f0f88d462a169d5b7fd2b2df52891f13cefdb1cdf07abe60c0ddbd772372f628614e033cdf784aab6adea43f9667348dc0afc5a812c035b783436a95534f81ba5eeab07b4931764211c21e1002a7fc549638ab1909836fd0001f97e8f0e9feb399682863fb9497997d54043e35ffa6b59b126805e2670162c7f2318636128d0b22f2e9c86fa41a25f59baf530d74624aa47b4dcb0adb005fedfb9eba9e0fba0d3f160ec6e34541519550adc208becc4eb93696cae2ab51069e4f2dab1ee40ee40162f1e27a3d2c7d7608a01e7c5e4f080f45d820b6621cc85a47ac831d430d806ab18ac3396b0a553d43b1ec0e46dd31882d3c8644c21affb1643d33d226aa327cbcaa87e953b534736d0b4d8970cb0118c7d43046569622877f988a52fbc1f5706caae66b2843ccb2121b4836d3056768c674febf71e8fef464f104c4ee12056a66aaa6d93767a85726290c46e58f71d97f0e1d4fc522afe9b81d2549e07ec19d9c790b7e1531de2d9e1ae07ecdc29cfdd7e38eb1d6aa70fef7fabe1c10496b75b90cf2d20ae1b4ea1c99140ca7f3273d2cb810f611366f3b051e91bf9254e6f11f4fb16c7faae5a7dbc8596ba5c118b7d7809c6396171a5015fc3ea550aff324a629b2a6f9dcd0d460ab82412789d323117bc4beb9f8c9afea300fd0001572673c336f231037a0c1495c61d820fe1f29e1bcf9f9ffe780f40b032f45ea0874503233c416f6257be0d12ddc78f34da95ab2f10eb72a4cb7574398c8444a875686fc171e90b587cb918e12bc2b2a0a830dacd6af7383bca0a690a16b7fbf61c7cabe7843cecfb89de7230530f9ce37ee461517f792c3f03dcd3dbba0a1acd5a03c3e5f92ba7acffa5671fa7154060261e5f54d3c472c97eb242bf6c0ce3c4ba922463f02285c605958640b25ef724bc7373717857c52c426d776f38c5c9bfe1eddad71865bbddcccbeeef7160f1295a2e9baebacbbe41f18d84253540e8986e8520e4f7170836d494dbefe66df77efb73497ba388cd2ec1e566368f6d6b9afd00012925ad4ecb2306588a3764dc8634dcd0950b479ccfce03293eea7b19227d8720cb768149810eefb4f526066c66ae7e9258dab494d931b14cc1d6f635079c87603b0040af4aa6e50f817832071446904b6ce6d90ac7ed7abf9859fd3256069583ad8aaa5ff00b8343e8f00006891fe36abcb62a9cd68e259a97ac6faef9a913e377d6601797e98ddecac50c34dff2df8bf14c0dddbea15f3de115a6748c50196771daa06ac5d7b01b0ca3ebf83f6864340eae998e95ea69b1f89f0b35d70aff3a1b9ffd33fd04254a00b88939ff0a1fb7b9eab5f20028c3c9214ceb83636a13b398ed8e80cba469f58c86c82bbe6fe3af5bff8fc99f614abc3cc4c40030e5f7a780677eb1dce7d7e8f960c412ea0b79a23e4bbac750d07915fac7d4871b7e7d05b8b8a7e20cbdfbccd4c77bef7297ff98cd6d78a9b9bdf60e99db8f259c5405a718ad063c8c792ae50a81f88452ebc48351daea9421834d4301cecb7f9b45c6dbee3a8fe390e89b03d895f1545c08e1960ced6758e80a809e5b3b723b173fa10805fd0001d40f312bcbf89b34fe021b64de71b5095dc11baa04a3ad84a58dfe2d72f2e48604102393af2cbdc6c7a3662a4043788653909e96393fe292bf4d839d31ceb4699e051161abeb3730195446ff37596e53feb3b61ac395cf0d099ba3c3fbe097760de50412c152c3246a0a3dbae5437aed751306fee541f08a6f0d672b50813a7d98683c97b732f4d81ac095b967c1cb34513b3d7842b647dc7e71804aca6698b325014a6b66ccc23b987c97e9612b2a271c2f3dab8dc547f91d9c89b5ebc32978d34f9939d7941f932acf61d227e1f9680aa1ce4024085f0d88d7836238b005eb8300aeb181f4d1e5aa0fd2c527d0e06e462a18959cea861c690e01282ad08893fd00019edae94994af8897b74739d150a0e6ffcff04821711c5fcfd764a15daff9fd45e7d1b492bd374a621a7a87c35d15da7bcfd203961faefbe818c2e088eb3ad3edf1cddd100b8482dfd1ccc784845052b12892db9388b68498fdbc0414818e18a22d858aea32db9f5e97b27ae4466553cd1d6904193a46c976cc9e031beb65c3864c74e8213b0be1c721142dac229a7ac16764c9d79157da54e9fa633c2feb79755140ec3832d1eef2514c78292513a3118c1c77df846ee2ac1426c24ae95c3e6113abaad7c04823ffc5e59fe6dce2b1287be58002fd95aba89235bacae6bc690c84558492ec467309dfe9c3a1c4613662e9c45b3719d28a3f14d2ba13d319f7808071dc4057dce6911fdf27106652a433f476f41fdd4690478e6f5def09d8a210ef000b27d81ddc69be5057ddd0567227c4cc6dba91b4370ab224e2cc866f9728a62ce2c080709e1284f69c1c3adce6a333e597be08780386702935c9975ceae57764e5e0ca25b37382a57de3216e21b9aada53b4630196d9c70d77186659f5e77f8054fedf4195c15ffd2023e7806f3cc45ed8efc85bf5914c975a64e2a95119656739e4db2015abc2594db44d4ae3f59df5df83e0c7244b12b3f06d3d078c250c34bccbc085f3aef6b036fd8a66c8f64c37389019df93288cb49f3692aca622b448f758fa470e89d3dc751cbed0aeda94636224e7d00be77ae11c9aa210b2ab3d63819ad9341b19a4da6985e6f46580046876ef76e2ad85fae2ec0290dc3ca8245fc0be0a5541d9ddc4cb0634d655bc1dfc3520ad7027ebb24a53a4cae77958a8a25ec38dde2e39b8e89bc57497d8a9056da2c64846e2a2aeb3aef018c0042c78fed9979e0c72b0b1629793f3298d0b6bf478e36774d2a7f3bb84d326803b8d2b4bc400fd0001bd96c2dcbeb599b505adfdf27eb8b75e15f400d75822eaef6a990843dbf9f805908d209caf138e21d8a85e0b1134014d25f10b3dee0115544f94538e16a9c0c167b7bf99b05d4e72f0362e98b9cce22917334e106679ce3f7df467d90b53a16f3dff227cc472adf2466b6cb5ef57473f36325bfb4fa3e1778b8367d9b327d0b91a48762601186630160b6357bbfa3137fa56d27de4e7eef5810b0f8aac58b517382f937060d8dae4553ee28b3a9b806966202b6f7ee209c527e83b2cc602035b6386da6f9f707cba04caeb0c63eb8e0fbd6e16f6a2ffa3660234c98c63bb27556b6c4f59cfc853ee8f126a6bb8a1875653ed1c0652ae713c090838155fb749aa81f07bfa5ef437633ea97c8c1c4bf50a8990727dbc85055bb527c559fdb31e8c76779fa849fc4828aabafc567800d920440d45346427525d949be473ce916d123d2696c6ad6434ddf48e503ca32b7386a10f7b6409ffd63ffd82a21aa18ee8329aee2e06cb2c75635ad597b5e66c810da5bdb670405fc32301fae03837aad4edbb008082130021e59bde8347f672382684d8c81863ab61a82f9cb8cc4c97bddc3999dfefda60e78f532d49f50c768acb1c54c9e0fc383b550c885584511a26131844a24e98b349c9161a4f667814c65cbabbed941c1fadca370d586c38aca411bfaf9bf4c78237e66801c0e843fad1bec12cab0cc22828047e31725a4b03d765d3f41dfd000132f82bde9b10cd449ab6ce249a12c34e26588a5e28a17135abe54d4fdb419aad7a8369fc71fa73dcb2be977164f044aef9412a22fa390198f1dcd1faa6a1160f9e7a0735fc0388e538fd211914abd94d8a7dfb4cc90b753e7bdf1313e14eea95232592bd84ea373f23124834d775fa7292fbccb8eaa416824fb7f41d5da756617eab26af10cad98408c0526097cbd7c3dda0a1a8cf8473716b6fb981a47d71676fac7d7916132533a2db91e741bac59824aea7028e16ecfe17de725c0aeb018c760d43ec824b14364f9b1232f19fd182957096199c3f0b5eeea83dc6f54a2790c75e170b8a4df4e8890bb37eaee654cf38791f072061a00bd31fae1184b332b0fd0001395456dfbf3f7a900309cc0a0b08738b917c92d686bc995abd3b9484a2d3e97e17973043095573c177a884e7c67f30f4b1a271e1f366be1e9ac49233939fce500b7cfe45c990cd3dfde38e4764176408e0460a0e7f9606e7901e36ad0e3e046d5297a52a57ea31f8e92030b112955c44e8b6a2cf05c78665c5088083651a5e5c95b20dce52838cceda0d0f9d294f47fadbd4dfa1188d87bcd08c7461ec3df398bcc50232d8d0ee960c572d2f159e2474dd6f5b5f86699813530114957f2f9237a005da02e8ab682e15c99e554e3da7b252e265a1141f5ba1f084649daede3a65159e0d021a861de5d08273e91cc4959d567bc5a240c2229d14d19899c05712a6fd00013bd482b1a0663e6a19086e942eb8cc6818617790b821a17063cc9cc4739e667840be68d773371b6ca8da44471472b1649a3a438ea8c5689e9374368af38041f0ef1fbc12f302e89b8b166f93e2393faf45ba9ebdfd843fe26b5ccaf521eada3f9a32de20d77d32623e94c987efd9b07cfdb45cd8e7df4debcf91027ac6c1e5b3ec69cf1ff631472072f3908e44c2e8df577c09a5944619f7362b902676d76625c7917195d256afe8e6b43d1c54c51083e8e3177d248fb891a8ab592104a3709267b83e2f1fa5de6edddbd067a7d7b04a39a3bb3791151846b96dfe25b86dcca60a04bef4b4dbe9a6c72a73e1d9e65134ad97d760dc78815e8ac450720826c685fd00010b7873d6e761dc5b6c9c841e10523db230d2cb67f0f9099c85e2f39824ebbbd56000d363d06b3869b0f395bc287b646d2f4e46d1ddd87f710519c5c807188d24210ecf048997047efc6491a0901af49bdcdbfa34b1ab5e37c5358058fce23f6e320783b71953072105e032d9ced12aa3e8d170729402919ccf1961209ceb80bb76395949e1306892b474a56ffe56e34416c77a952c2dba595cbf9a9c99bf9855f59e6df3a83c258d10885d8af41de65a2116b04a09140eb59eb8c182758a79cec5cb72016346666072868c55ab72ddadf13269b069dd4f56ad865f9c56640bd96540c503d336355383fb0dccde72ff49a0f061cd85c113eaedb8b8f5ad4fb7898008a9cc02057df3d702f1e690667a0cbccdee0247f78c2ac25aab1e08ec0b3b029fc44d08eabd4f6ec634d218febed56028c4a6502c216b28cceea504f488411221d6aec4f6579d64d1f104f8ece40c1573991c65b1e619d49fca77c9154bc4c53df364680dc0413efd9f1b4f054058423e6b80d6ee46d86f6aec2b99bc77031372e3bf1aa9c8e6bfe4863900125d32c72c6210f7b6909b80f0016d7b28033fcde13be6026bdd74d5fd9dfeec8231c145198b87d3cd1db5f96f9e656acb2ee7f7557f928d4f14aad1c6ed67dbf2acc946d03ff9e0c047bd0c568961f5e0b8aea35331c1b6f8f8082b42b2e8740b947186aa241f7b3d9e3947a0c128db59d0f23accf85db2e9e3a779ea6abe48c5dadd23f39ebf3d22aba18d81fbbc05409839afb0b1009908f0654924d50372b4e298baf761f1cce5773d7055da79efd32be0bd74aed1899871670aa67e06bacd7c2b45c3b63ae483dca472bc2eee420407c78ebc0930ee80809936f13797fccdde20acc1d32bb0632de8433d0070e7f86399df804ee16dbdd805dc0a70bd460e0feda8a8b56530f07876df53d0ec6fe4126d4600e10d125a741cf676a822880f5c6f15141c2591ce24fb1789c1b029449a86525a01faf03701f7fd68f4042d511cbd4373372766b69e23ec98d4fd5af4b5207c49cf2b4045fe752a7b5be995a3d2a1f5379e04febeea2aa2601b6af234a792a324834c54dbabcacb46084ad349f002742ad3ea42a1bd972d964e4ba682bdfdcea421d30c0a14c77715003537be3fccaed043eed5b3527b62fe3615f3cc0728914532dc26f4524c414edf5d62dcf045e05865e52c21a30368cd3aeb10eeaa2174cbc84d12e1050fd1c8432594bb5d07dffdd9246c82b9070074a27040145d7e96bac27cec48fb402333ff858822d2053bcfb07567a7fdc71d9e03daa6204a8383321ad2ebcec746e77c7ac5b562fe478f6a533208e0ac145fbd522333a66950948047c936659a6760766f5fc812012e81bdb6689d932d0b040ae6c1dc4ba10fa278a83452a348dfb2088e242c464460f9acd9c3cdbcac294f8343b78a8fef8c62652eb44407257b20bea80be24d4418c29dc2c0fbd274a7c77a0c0f129e87200d0fa39caa8aa168497d3f33f26fe6243a81a0ca7bf40d613ffa47434c0f56224bbecce7f2481220e3b4a0a7941157fbf2910b592fe540b761e9e81adbd1646b299564050f0a6e56090000000100e40b54020000001976a914c0a256057bf26c1c54e6bf168c216165c5216add88ac000000000100000001560ccab26203301b2f5c109ed9c6bbc90d287640eb58bffc9759e1d9509a0fb8010000006c4930460221009dbfda3498e7c37271bc39aee847fc7a76891947d0c9ededec6b156920fbf6e0022100fa206e562974e3d18d2e301359120b3b5f9b94536eedbf7a3c5dcbb72fdfd3380121023b65dc61774717e40a44e3765d8974b722a03f16e091474d93e25aa55507a131ffffffff028cb9897b000000001976a9149226c17c6f3442858fd8d2bcada9b94f67d40e0a88ac06103a03000000001976a914b80c0cc5f21ec28f39fbd5b0d62ed01d461cd3c988ac0000000001000000013eb6ca024b6e9f6239f762adc1eb9d794491ca70670f4bb0539b25a3e108067c010000006c493046022100d163e43d054bf5a736184fb7680c556c9b25ced43eb6024572cc63b3a5ab0564022100e9dce83c66e8e5959b94ac376b090e95cbcdf3a59f50f915ff54e6b12fe626180121029ff9583e35f339ce9bc495d8e3510416be3babbcd4dc1698f5ae4f1a98bfe740ffffffff022573ca19000000001976a9143930af073a625888677494766b1ea474d2fa080c88ace6bbf629000000001976a9148a2380c80edd13a7d0a15d3e5756d14e15d269da88ac00000000 +001000200a1acb90d3b4a49cf9adcf07f97e5ed4d77ccc0cddea63881ef7525ec217c11290b92fb09e8ba6d1caff73ac5dc1c4f619a9d75f7dd094130644215ac58c44992d38e05e380e021f4137000000100000a8ed78588f208b30c54cf908449b2a5ed9546a486f283f86b91fbf616a58000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5b7f83069ac800d85641f5fcf290293ea33b1eb251929552111f36646f229ad1b64fd06e1ffbd30d82038b2b3293561f6a5eb3da7f072713a4a93ddfb7dba91887f3f1063ffc765d1985ec1d6e794456a52ac223db410bd72915c44cfd31d29d09a072fa8a993d1a9a7815173b5b568454ebd8733f88eb2cca3c43b7eca0c897b8e7ede079ccf17e0404ffe05a5c8ae2136436749f1bfbdef0c8883c36540ec5bf5784eeb802b7406e684801c6215166499b00e09c637e15aa64fa820de15bd095e2e3d8954c470001ce23cafc59cce94e4cb8479d05a2788acc9a49e47fa053822b372648450e0a12b6209ef0d8fbefb605477b2a48097d7d2c68d81f009806cd2b530405a243a3cb1badaf79bb0d61002620fb55802c99ac70a3c9403c080881403075956389530c56e1ed02856be24cd38a2da190d629fcc7cec3ff65e91feb70a0a4c92f0ec28618ed49a6e7a8600aa2121be20c06433450248dbade3301a7f0044636a200115c2c917856fc1df5797844645cab741d7f7b867ca01689232a44f622b6f73fb67e322ced9a5eeafb7df739bf719bbcdab46dd9129efe533a979a4c7613f1550cf5c38be5fee7c597ca481d9b3def7894765631edf5f4222d1e22d2bf4c9d3bce0ed2b72c32e937490322c38dd1b1a01de48472eee4f5b10b83dd34e3a3ac7e0cce0de2a975d68a73bd272015db2d720505746d44619220f382b876c67a33a8e032a65a8b789b36c8136d875119bd40eb790b0d07e0662c0fa5579bd225653bf94ac5e51de4e24137bcbc7b69e9c197e93a2c7028c16f0618c948a9b62d4ca8a22f40f2bfb99b09488930d034bb13660ac786a02a06fb5083387ac83d4fb748a46757242022ccc738de6f51a383cd61b96b7761d1521507706546cd09b0a9a6d543174e7a6d3e654e6d48499ed45f709b60da81457ff773ed7a7b203a842788370087f35fcb805b142a1580537724b759d7af6092f79a379286a11413fbd463fee94bd3390df0928533f553b35139fba42f4b13fc9ed73aae54f3aee4609e90a24111a49ec44aeb97755449d4f777052c23a112c0d21805491cdef4d36c95907d0f2f80a50c0bb983b1b7ad755b0173a4c8561bad4e0df02e688bafa6769d079ef9739e10c62b09ce0dfbf80068f1664e03e1dbd26010d5da1f493811eb6fd4b3b83c3c547912402b375706a0e283415aa0273469483a167f58fa73baaebd8c170333223fa88d273bf6eb16031cf8d90f746d6e244be9cccb7184d85f41ae1d8b97ef2bf8ffb33fbc665dc7cbf969218b24724e3c7c88120860036816f74f27df6d00fd2784281ad3f97e8dedcb76d96bb26fc52bd3fa0a3f58e1367e564959ad2c55534d61e829c55b543fe2bf45250bbeab1c798e0bfc0340ebbb4acc7cdbda31cebf864f51834f7ac557991d74720da1e9534175deb34255a964c481d38deddbfbbf2bcf86e560426db875edb2392928cf4b21f8cc42d1d5caad6466ef6d1120f725837febe2cf8be5fbcdfdf7f9682466b289a9b07004851dcdda4538aae48c8d44c856fd63a47af727a101c5df2a16cf7387d4f5a59adafa2081243e85d9f27807c004e0d8472ea7c4ffacbc5945eca307a8921d31113cf95c382bdcc83c27268254f7133aeb61e0c1ceaf69628312d24e2ced8994172ef6a5c2417c5eb7412137dce54bcd11062caa1d2ee2f0a85c07b0b47745e33feb9f6c3c090f1f6721fb86e1f784d3bf133a7f912bcc90b852f7104e561ba72e694fb26d9bbbf774cf40c0a7d17c0457844dd8c06e7077c93b73a9152fcf19447964fe9b0796926d66c99ce368077409023f5c95abab93d15b71d984c551543b4b8e737d80f2185f7aff6ffd0c4faabc6698656af6c537b0d3ee33ac21f18d40b89a74d08fb85941bc96b7820aecb32699d160da72d67e0a9020a1f5a45ffcfa161752dae11829d30723fdf40591dc853dd84f69bc9c7b24514717e8b2d5e08f886811c568961b40338af7a4810cb7154d8ade350340ff1efae35825feba07a1b72e58f9e3a916b9c99393102fcfd88cf67179800d9762ca66de2f70833850457c69b69a7d21ddcf7c058a5760567e1d90596764ff1586114f9c30a4cd80fca24eda471def0122a36ffa86988b64ea202416c076e1f258796e27ad926075ce3b16230f8b4392fa0b1e8bae9d74a75cf1d31334d4853442bf1fa979634009c225aa1a7108c5b17b79bca792daeaa6a8fe6121f3b559f2cc7e1006bf2295bfbeb56b858f095542bdd378b3375603ec5b5f194a5356b9623ccc65aecf81185312ba25fe6117d15c6a90da519eb81def9b1843eb4853470e11c99f529d83e80444790267319649c3b95c4153c947b3d571fc77f1d060289f2010159e1f7009268b8155d5f1a35751e84b4853b5bca1a6590ad5bb638e133e6c6edf5c6781e251b5b1b96478444d8b29692e7f602dc954aa51f5aa0c8e3637d138b30431e8e702485e65cc5cfb4cb2c86f743de03501a8575a2e19f886519bfd4c9c84699279659e7fdea6b9099d9b1493aa484b6818719bcd3c3c8f098979dcc31b573eaaf637df54b917c3733838174629eb897477921f88037159e6b5b94c18f3a97466b1277af53d3f2bfce1d5dd04bacc413e7c022470c2d98a8fefc62eaf1626b70f3bc91931ae903a78f7d817460149ca48d62bff9f8eb7bf2b3b9c936c2d4b8cc54fce0a7a53b526aa87ada86ff35cd163cb7a875bd0651f975c8ea849b863f1d864904294933da1d765e1504131dfa9bffcf869d6bc9769eee8f09e3fc11b3f370a886b2be3cd8f6b9b2fcedab91feb687719d3f2145028a78cde5074a9cbcf2cedd2944430889fd8790565e35ec3b3e647866d29769dca09de43e28aef2302c39d12f753c5e17bd4a0f2a3f91572a8a5eaa82975c00582dfdf6a18e817ce1ae66bd60d5da56a9a14f5bc2cc53caef1e90ba64d2e2494622da832bb8317c5d54641783488ad1e2895f22499cf152601b5de65c3b02591380be9361275738861cbc6967f437472198f0c7aa87c7d809dda03432dd0dce8a1554fc1cd8539f238d16e1d283e3796e98a3d4656f888c11cb39c9f54e9cfa85067736a73174b28be620084549d33586d83b394df11fe46262b125d79927ef71fa799d5739b38568461ee46b9baef02abb64b576afe8a4907653707664757e12f6055f8b559355e4803863088f11f4c3ced6d61d7fbdce5d42c5f25851561036fb384c1ba8a9dc39a40c5a64e2d395892b808802716f25868499086fe1c2822948324964503be10156bcd9512febc98b79eec49eb79dd269c9e1e91e634d1c2acfd80c4a4540191b876cb5dccb550c44db1e7fc212396dd7345fb1d8ad135404579d63a7fc5a9d2787e9a5c71cf2ccae8c1f5d6b7476345b8e21a518d2c9c0f82196443a972c658f482fc57a4c1c7444befc167b58fceedc0c71c96d44e2613acea4a0365300076fe9326ca8bee3328661b6f35c0a973b9711a3b176bb95eb9f51d0477c0c77e4fff86c030d8ae5f477dfa0235e71be276a29b1eb56b6df7ca62c2a12cb3c67e2982cc61b69d7e95b9570f95705f1a0ca4091a4709d2960467ee40781da8766ea3764cce0b20157b5118449514885faf4255b65a533068adf9ff8d29b943ce9876a46f3217031c9126db8930eb4c467e8b106e557140dbe48ed4641ab211138db50d7dab6d179390ff773ee748a62b31a78587c194e8ec7d0dbbf9224ff6d43955938f05463ea70b578e5daade5d032235670e5ad98ce437ad71a123747f34e00bae2a5ec38080c69071260c58d48876fad35889750a57278902ae842cb50f01f184d0468feed4917d9c956c8ca3c554132be73c34d1befb81f633c161ae6b5d983d9c7d994a5db77ed41109a90bd07c62a6e36f872856fbb444f7d9efaa2b33595f06d51d227390e3eb3c721d82ad95c688d70d174c60205de0c3c1176eb6571710d34b3cfb30bba75165f4b8357e39dc5eae43cf0d15f5613d53352fa5184e486968267f1e406df8ea79a90e02cfa50c54e4c74d3bb00e62d9b2853c4b2b5ffba408eaec11cb23e5698d59baaf7d99eb54db56a358c331dca730bf3fc7a1978718277ba93e391431bb649fa3cda97e7b39494f9f53f66fa6b91e5bfdc4afa467ba8c4ec0bd6ff4d72944406473ab8c887852ab2dcde2273c6d6694e62c28525fa61cc5783dfafed8e9910629cda9ae30f61f8f76675dd5d8d444f02921ec17b92b112fa9d91ebb742610e3c7cbff421cde5c37f449588a44eba0716c36ab7e191e3433278e4efefccee1459dd33dbf722946b87f2afb846f1a163a74c6c48c4cdf997ccf3ac9beb0ed47cdc6da8aaf73d8ae703a5abfa801d427b6f2b07c18886c9162eadf54318933b3d3b4277929111efe5892eb38b5863888ede6f200ad7bf061993b60febf4a11db03652a9dffa8cb49fd62b9ff906fcf0321a35a3db580d76bb5a28264ccf46dfa8ac14b56d8a0bf04c26846148e46ee69364b4c3d312aa548332f8a703796608194da0bda90cebdf7dc9531339477948f389d495d721dd70737d989978200a9c7e14286ac070b1f73cb77c040e647091bf3e0ce947245e12a20aec8defb73895bd4d8ca37729e38795d87707bc834743d82d9fe36a4558b167dc3f308fa791e2bd180196b5dfb6f1ba34f0afbe527ed50028a5552b9a08c9c72e6dd98f9555bbc3a6762a9bdfa0427658afd3c87e701b7f4858065b97e19006309c99718d2e0c5eebfa3793143eeeebfff7ea499c7f5fadacb532d1be07301c72db9b3389c06924541fdae62000711d911fc868b4c3c6663796c72d55d8ba77bdd1927045bbd9f5a1f97dfed284c4b4aa00fc8b1937e91cfa7bab5840faf5517ad95bb2506384c3b6663c54ead356ff41bf2f05ecb65278010a1864cd0c3d890fed503a1799ae9f5ae691a28c6eb0c78696acdc732eeeacb62e6d2ec35de25962de941fca12762cdd3fc6661430dfad323e08c7d3a3b07efa7ed2f75a1a72bee249c790b49d1227a29e9a62206b9cd32dc6c44b134387177f662f56009f107dd077f3def202bd38c6e1ac8acd269a846093a880103c8489584b86306b77f4bef519195d73f0b6251e2a71d168610c9e4ebb499cce6752da1c7702f92ae73e26774a3d22ed859c410b5c0d44df8d513e41c7d22a1fe18f6040c4c79a791bdf5aed7af5ae8b31a924eef831b9e70749bfb6ce25816ef8ccc9fd7c4a3fecbb3f0c433eefdc2d254963d0375911fc771af3f2429b62d1ab917fb9f5fd26dfb14a413656d8cc77ee6456ca585191642118acf7a8f99d9f811dd4b3a408e4aee396f404448d272559ae4747323a0058ba1bc20ecce9f7c45cfaa103e92381a2aa461886a3f2ab9b4a88b1cb3d6d05654327bacb61b06e742508d17bc877ca47c9fc97fc9690ab9cf80f620ea0f19bfeb3ca44dde002ebe8e439a39070ba37897acb42206f6eca39a8665f8c14db8cc32ddc0ffee53e9bcd0b318568ee227a7fe864b6ff834b06dfc0a33911f7d30ff0987d84029df429390e44c37899b37365f88e60d1e181eeb2e9af3a2178cb91828fff1cb2f74c367f2f40570be8d864705076e2930a0cc1200cc2c512c85fc3e363b2ed695a5095f4d06ad48b16a3706e968688443ffaf4e87b239f2140a50e4b884cde82841a935043bdc4d9bbf8e09d29ebbdfda59c5aee5c058f4ba68deb996ef13e2ee09e72121e5ca779a0237de67acccd0cf3c70680566f5f8005c96fc4c2480809b597ed39284fdc7e0bfff7e371f60f13d8ea19a30cce6239f5e405b8add93e30c278e0e26291ef7faa6ccd467d5092a1253588dd9ca14af348123e9003ba943ed2f60d57010bb38822edce7a8f92bd0b5dfbaaf21d9ed3cce4232b39b868dd65fea42dcad0669a0c6ec5b28d5160a360cfe98f9c2c05e156160e742e409f5f15bd0a89d9350e452b941c721bc83f4c070aae22545abce841c21dffdb6d0b0806222ae92a96c67fc228220d35db45876653802207073003b24893e6c9fce2085dd1a19f4d11c887e360dbb8eb32c6a09895265bd07fa62ba378d3adbcf9b8191e7c3b82013847176698c4fcdc189edbbe4f690f2560e5e9ef69611f9ff27b2d2ed7dab104a706be9c78b533dd4f6ad623165a628ae7813ecb4ffccf6abf1f5398a91b25c34fe1e129d8e4b1cf788e8d89825b1c3e3188183829cf26acf2b0cc1d9ca83f7e8d5fe05ac58106bc604ee3f2b69cea85fb757da6e80a9b751957735f3133b6f7f0d2376d10df4a69d6ba0e37a6c5e75a9242c2492645daf28836d5fa687ed95a74e013dbc31cf8017296a5d6abdb72dffa215705ab04ab244b343cf1df85ceea09059d711c8ff6fe429b19bc0ac6d009e72dd35954f8c85ebdd41244b39c0ce7db8ff85f052c3fead6befb98fad1710129752985a05cd71311ed21e95f27a55fc4b49f050d5f8e336b8d66b52cdc8ea5951f779f1db705e599c863e398a8e385342c2b9f7ea9c0c8499804b9e6ad6c9473bfbb6e34f455b0ffd1ba64f5c50589e5ec0ba05dddab74fc9ebd254bbd09edc31faa3bf52e851accde5f1634567ac036ce1440025a53c78b47b6d608e5ef59beedae9afb71614859b57ef4c9909f56971eb0a8af7c9f0ad2f20114d4e379a3f3db06b545899bf6d951e17eefad5c0b7d030f279acb219a00ad80e8f2799cb567ae76da136dea817f9fa1b6897ecc748a8398328003978e6b1bf213a6fdf8a225c70c2c6ba60458618376249e707e67d3d5d76a6945cd10edc92f5aa9ea5ed39d884bb60b9cb1642f04f23a967c1b7f29007cc61ab99888e37be79c31513dcf4710997575299e975ceebe8ea711c76438ebe000654416161adcc91de774c7c51e3700b7403033d77a6f5cc20290769bcb47662abd76f181317b933dbe37b0f602a1ac61c98ff2cf514160bec5f68b7d7c723f44c40c4c0329b26ac705894c398f0ec95520fe2242d36a1d1c94aa2acab65758006d4c567b1deb63e70ed4167619a17a91b41f6d0af1678aadd79cd8910743a01123b6c028c8c273c0d0cea51a2f3025d5c7b98765ab8fb6e57ebb739e6e819d865a94a9beda4a3f21db644e6f6b813668a62944af4196fa5441c1a50a76315d5134c3c38b1fee62e3e6c93250f697efb81308fda129099b0d68c639a0e633197cff3ce385a3c61ee472bec8202c389e11a8bebd17dc7923f0294b494f1e180ee93729dfc5205adcf2147344163c0e752905ca321455a2445d50561d7364700fe5f92dbb4f3ff95ab21a3e3b385c8fca67e03182b1c0436bd1396dc2992052586480050e86345af7cca507cd03f30fb3e0cadeda604ab3043ee9ccf65785b7351ed9c00921575ce10d02c94109d13d7ac4f29be5f4b29ea2a5cb889ef51f74966b3cb752bf073dc4f9629ac444a8894568554235c8ce502f25525505e14d9c5f8ca7b4faeac551fa01c81e888b3b02af382b5a98436847a68e10d6fa3eaeb7a5fc1b72059764c2bc9015813a3f8027e873ee21db78d75bc98b65088219a3e8a82e23ac599d0f1fee9f9b78f3e83c3646949d6f0e17d51caa3b44ff3d0fc10925461de077c5b51d12fdeac5a11d9607caf3c2e68d5b57da57d866d279b21983a47d149a0c582b2e1811b04737a929a5ebbb742f7e4d09e2f0a2007f92eeed3ae3be17f7ba764c913c7d033c0bc7244496744bfe3769df9c9eca38a718c976d4a2765772aef5f4f2299180f4f78264d7ac1854bbfc1615724f7fce3ea6730b1aa01ca7006ed5f72d89631c369c9e6dc77cdb7db73433608ad756cec9d271b09247b89156b82eaaaf55331ad932b2eedc8da665bdac4cf603dd7d9751eeae5a41c0144762ed0e083b34c0f7bd6c3aa8ce8c6912bd39de3fd9c53a3b20a47d4247f59312c560249e36eb5ea1e47d7736a4001389adac316b762a5a3a72bb5e9aff6ef5f71545c5f32192f2a2d6e3869d9c5dd295334b44645fb844cc3f45f15b4e45dbf98402986323923c16a4a2514d6af42f5216010a1ccb2944d8aa91f806a08130250b71170fcbe3c7d0234be8ad5509ca67fa9e0056361650b05d8cdca7acb6036bf3b059a8d717aad52eca073fa508bd0327708affedbe77fd127a0e34d1ed9aadc36f18f7b1de2f463a98e46e0b2a1ede583d59878f2db35c28c25b8bdab875c812fa3e4541e5066fb4d410c1fe73a9081aad7edafe681babc761b62ec3e1fbfd37e76389f77732f5709535f2d7cc5cdb9a0bc86b9dba4051adb94e2c5e9da6569cb2d31e06231c98df50896707b8489f6a4dd2d78e6718ddc3ad1276625d009819a0670052f2a8c92112faf2fea973295fed329fd0aa5132103b14045636a0d710bf6df6982b15c9dbcac02d9193e5622cfd9dbe08fadcaa957aac28f2d018c3a442308712284855c6dc09a77a3dc7341faf944c62655ed121de144a8e2ed81b3f55945401d844ff9cb8e5e7005ee5a1e56fbc2f3c8f8b471154ea1c695dd83072179e5279698ff4494bf3d79b0c0ae289df647c1724477fc5770876a6f15f99989ee6e7590b18a82b176d693e65dc65acb0100d2c3a3d9255b4ebd70163e88b282fd792ab6428799be33a480f3ad36db60f7a2a0111327b5a38ced3c969b659ec3229de5089fd0864329383abe778ec98e7bcb242039893a47180bfd49cbf59ae59b2ed66ab00b2addb5fb671ca0a2d27db56a6b48b5b4964deb17be9ece5b6c25a3f5143142e26b95dbfce819f0af70b5384ad938e284565e2c9ca3fbe0f512774443c0a539c6634dda0e79e1f895138914e9dac266c803e0cf6734131b1415d168743a8a9f445fba953137a1431f3611e19b8f9cb47bab3f8d3f62f46c77b003d0634174c38749340d601130f43ccb76a2d9f1cc2d2dda393fd7fd7b22ac32821e107d3ada18bfce4010c2b413aa6a849b252a99d2c704bbfe563cfb058e6f33ff79558e68e36967758d7098ab06929d904619efb1a117209c01aaea938694511817d6e0b9b43dcd87f9e8f9523cfe8df9a32d31ecc6739b6e6f0f63c506d42930c49ac1771f3e5e39ba9d5fb419e54bd39625282cdb6d7fb0c1a6b0db5ac6666a60d731be95852af68913845d8fc1eddf20153318af8c100e31716852864559739da8e1200150b964dba8fa2c9302ee62f3461b0759f0df831804046990daa31d7b1b53e60088bc9e427a1f83fa1a7eb6408a1d8dc050ccb75783b8a69b7603e9c5ee98c75668e175af3bc06dcff4fea54e7d275b76ded4a78bbaaaeada941085af49d90426145a8da720c432881b12bba757baa6f50cbbea318bb9028fb475431adcdab322f2a8f9a657572e26602ad24d911b3b982adf45b9a1052bbc0911f186e18ee758466a75f230be0bee42fe8b5f3ac55d499cef00b900f0645ff0f5bc604b5c39a0b008322c2e2e990431d5805e8c70d4ef91b4467809c444427624bf58a86c5231fc92474bc560e7ad20778b72cb2bd1bd84569c359c3b94cf0d31aaaf6cb3415fb326be56a35435c3f40d423cc16952a83708fb634b54830f34f373534e2e4d5c8860460daa297f0da8541286bb0a40c515230db5146911c67fad466ba52c2c1faec2c14b9846c869aa8ca5b9846abde353bdc7cda0518fb95f005bb36e8f4e2ee0ad6a1dc7f7c6bcef435f5c969d581dc1a36395e00ca8e5ee70a1d3195757fad77627799ef674aa05ba1db02420955ae32aadf122fc466051b71d5e4f8546e306a7a16fb2915d95302d06f2d584b667f3d590d8abc68a9d04e0884028ee9552f28d283792916304b4fc1d6a311ffcf9e94e51166528109e3a52b0d99c925c53ea2084cb91d1180dac088c24df94afed94b9b0d0fdaace5a78086a44200f56af91fd731080ddafa2ab209f02b5ebd10f5c1b448f2746b23112ad5687dbeb0768f4cb6d38d5504f477cef605f0db5d0fce8190f99172c126462ac4cd35b6238309fbd940654e95ca4ab8500f7955fcebb3e5b06a34aeb9da911aea64d8f3512521b43b48809ad34a47c7335ef9e4454c819ca6d996be5e9723655455fa1ce1ac0632531a9c81888d4b2458ba435bbeb82c8400bedddf8429da425a021392a70b6bfd563fccfc369a6efda89f2ad873cdb5f31a2ec7faf132d021d95f667b8ae32b6169b528abc3e0d57acb2f75421560a1c0997906094234a433ce405c23084e2cfcbad49812627d66827d2bcb0f2344e08986cedeedc9f8d516f4218f8cdc577a9709657ab6adb15ea04ce690b2c2365f1c978693e7053f08741d64e97fde9eead6e28398982a15dac60a6b0234e4ab9727a32c0cbbd061530946cd00c5dc7954821efcca608ec602b31fe39ac713dd63328a95fa3f8f0aeb3cf0a398ff10f845aceb8f8268a0e9463005be36c6619d8cb435ddb2e766ade1d569d975a5e33e6f2a779cca754394999b1216826ad6f2ef1813a42856b81485a08438f0bfa50b9d1349a69a9058a11a55cd844c2997478adef390f692ccfeeb67e20d72a1af02ee6dbdc17e0705117b3fda41f1b578867a309ef8ca28cc9165aec1ca91d42b1e3b3b68fd57907dd4873c0147239ee02c2fc2cf770e2ad547d16d1b6a448545e9f2274b0f10307cafbb35bb34285d2a4b67f31eae22c8435296c8000d5512a0a6cbd70bc931f20931efc7e92e683b8d047d73f380abd47c2bed3060f61fa766cebddffabae14bd9afa03be081e0bc7bb7fcbf10d94fef8c5e8663031b7038c433414b1487ee6c3fa6af8e43b2f4dee10620bc42944de3d8d1ba588bc8cebf0ed4f91e37a78b3ecc92bd04baadb728bdbc491081ecccc1fb1b16fe3abd638de8786cf9116ea8062fb16b64612edd7a9b0332a328d05426a3b13c2ad8a0091f363a0b27442dc382651a6cd0765ff6b7e7004ead2579108645e54f5824834384eb30df1535b3a1b4642f1f11c136a2d89f8dbd34f80f087913055bf701c847bede7773ea4ceac8aa7bebdb019eadf646feb8336f9a0f7feba6ccfde9f3217950f2c966e1e515bbe0b983697946895f500eddfaa6c9991642af025ffd5442e7309678c7441e851974d22caa08d2cb047f26daaacd7e571d815cd967827265341e8eb72a273d9c972ee97830593e419e4762092305e15c902e6663af8d59ad2900c7afdfe960abd4c17a12ac15e8b23f4297969572863247b8c57a9428caa5c11e8d18f434fbb5283a137e0d7b5d0d3e89d1f8334f97c8790be33c7c5d3cb95cbd5f5987aaa74ee41ea265736a50f5a5ec1393255795825609881d086390c0ecd9637681359a37b27aaebdf704f0229e28263c22656792c470e826d3ecf31f8d04ce55e16169f8ff3c22dc6cf15ab76531accb9cb717200c2c309974385b5aab4221fa2380960cb7c265e7762c22a1c22e198039c7d01e6d7caeff3332802a6f214045abbb60de6d862006aee026dd3ac7cf8771cf7caae6d8d3052402b707db9f820e46bde90d8dfd67ed326cab712a88a64fb913afac29b83de0258c12707827dc421b8c8fcb274093e44f2675d9559a95d5345f86fb8def23b408bf42eb3d9a83a68c03f003cf4c84829acfe3c8c114448688b4d729fa851c2e99cfb1b3f1ad50d6219c187c7070292652d4bbb78a99896a37f500aafd4f3d789b69da56c9a3f649c7b010c8c2fe7129dae8235ed40623bb937b8f8681365b3bf07ca84eed3d60fd9fac18f5a9c05d73f2d8067a821399949f6d5d3b5242516939d91190da80470e1bf5542c2c9c8dffd04d3c0475f3f36da05c70f231d19f73d1e6496dcc2fc3791c643fa6477eaf3e4984e9df6f1d465d85921465c781e59426beb7c41aeac4d8dd9130cc68b59337ea6d7d05b3c2ee24e75fa1dc351d22ac5ba91fd21bcd8e7ebcb602181c469ff9fd62740b0a3fa7ccb31213c535805b5c7118c6839cb4e425a0e6bd10c8a0616f5dd28ba9afbbcf028c41c0f825336e45d7e1aaf661ab777fca15fe5179c4d49fbe1d1d07e3ecd54d7a4640dd72c2825abe6519da8b80cc47c2a6c08175dbd8d1cf8fb4173d066e4972d25845f0b7c0a50223b5aa5e79217c13bc66cd2f64aa4290d9a829a939b1524c61acdeb187c5699353438352112f3c453b759269f6d3b679932e10dc05ebafb0a435e2571a17a4d763b858c43bcc44894cb1b18c9b91ca2aa50827341870408e140010e526d0a19a4c1aa80f69da3ba3a857f3b13b629c381ad034fdecfe20b0a02419dfda5e394fd001f59e02eae7156b7770af9dfb8ce2a49a39b7186a5f3bbd495f866457a4d0211e6b9a732fd9586ddb575e31a2a6ee58894d2b4e47acb7c21c39eb1b7c1154219396909c6d81bc934fa8811140d104e15ccce16e3af78c1d8a18ece0fa703171e2036f9c0f630f337530c39496313038937f0c13b1979cddb87f2d051e066f8cc3321e07bd374aed3d3a82a27f2f0dd3f42f6c0fb59c00e6d273481d01c16d69ff1ce59b1cf94690c8d6f1143dbf288049eea5456cb4b25a3b9759bef7e6743de3886e6059c3bef3f213964258268cd30f9b76c87d4e55f55d5aa11542e17d93e278782a506f94008572dbd06393e5d309d35127e625064ab79722f732156a36c08f08ad7f9658001c59d92d8e9afc459a74a1b53740d4c5da8b9e311fcad85f0f216ef817ceec7e08c66f79ff4d83bbe304cec8d1328fb19eeabfe675a51695a8d954e3b218a508d1507bcb1d0ea6ba560ac06f8c8959f962a04b72c80ef194f376a8493e03b97c8ee1a168a64f04f1131983f14fe7f8ea8de8f045befd4a32011210a9078315587a8f2a5c21e4f4898f25df4044fc46d1dc1cf277cdd13dbf9f4fe1bed3cfd5d94b1024b117d36cfab7a7d8d18c7294424e4d67d4019cd1a8a12a5e9e6b49d1e27e3e2c6129dcce7d813ecb62d7a78c1e9b69067793403d94dacaf68cf9aa5ca79b7ee4e087f83b1d6baf2eda247593c26b6efc93b1a9f6b1552028a11a956cb932c10a971d6cea1daa23bb215b67fa90c39aef3138c7e2fbd46ba669c3b7c25001afab39c589ba8a4554addcdf5c10adb3536c816dd3b227d5dabf1f2b2f4c5205a2c6da90dc1743076a429a7771c4d45d50e75113113bf3f65aeb5cd60ea47215c8d858c069c157bfc2b25be14a4a2349b4c4f0084430947f85f4a18d4f9e103c87f14ffa48562540335ee7392b9cfb9b9e71f8488d6bd06d6e804236d00ca6d85864d6a628a79be098a0bb2733a6d38204e0fbc9a0ee9f057a6d04fd4f76f564038750d789e548d5d89c385b6d28801139665142808a1e9c30c991f90fa26d64fa1c63448b9db71c8018363391848c695c26030c72d47dc75fb8b54de249ce0dfde1b7ed1b99c03656d89efca8cf6bdf5f624b4bc7a0c5f5379a35cd3b14d75e677584e1598ff6400c0ccdcc2b384fb2a7041081a2eca42198f1ec6a3620e72600559ced762c49047345853705a962f112868c1b7dfa695bfce81fd68d2345ae4e6075f549fadab48e45da64a9df3bb44f92a4c73b8dada7673eec0409908183f56bdd978d356754664dd313ba7816271edf21a4904609f29f005b6feaf2764735523762aa03a14d31f817d20daa3f9a87231372a59170c56254159a372dad842e24ed9665dabcf17b9b46b2487fb69801cbdc1b6c4a2c40c13ac6433f734d95b5dbb5fc023c3c18da2d12bdde5e594f240db47eb16909c881af4260d567dcdd792c0fd000b2d5d5ae11477087e141f4b574313e15f9d3d89de86a3814072e4b8324ecca25b8cb27868ad1f498101cd9804e2ba67de7eb2507acc4d5d0575dc2ebfaf140a43161c443629befbb4232f7af81c2b60d1d48c61b94c33b2061ea2de6f6f7abccbe10a1938a740b8d1fcf5037b3c9e22656b7872734cfb208adecfee1e533b9cda1e04ee2cb58e20682861cf86dade3b5c5b6e3a44eb1bcd35e4bbef0b0f64310638914109b24122b3b51fdf7914ab7eb9bb4f3b7a7cf1904393a83dcebf0f3858d1a53127c5c837aedc887a70d19c51feddc9b755e70bb10a28530d70c6a715641f43829802e4a9f73358acabbdc4623b03356d7be2334fea5faca2e5e90a5ab3e7a265f4b979ce7e0c2c46221d4feabd30e7319405ced793ec5e59ad4b9778cee3329f386452276ca7e775b722fc8eb98c74374217384f4c8ce5bcb993762e202161b02285df84e11648336db2eaf204e338867a06cf18c18517c32f39f42b55454c02e3977120775dabc5bcf07a79bb1536f8ff77b6089e756ddfdd77a290de585cec3dfd9918df82b6beddb656352d0ab5ae0b3545998d40e9c909b10a4b055a857b7c6106cce7f9ccd9a6cfe0ccd9e7920d1bc21c5a623b42ef590544b2111580c8f8bdba4c263d7d1f2c8be46d657fa92df1789fcab298a2085ea19feff0713ffb7b9074ab0a1bfaac27e8bf695a1efcc66120a34c16babf8cb52c8e502dd57b000f561eab61f8fbe8078613e2db553ee4c50a865a6f067f2c718b5ea7f46487edfb30b417ab8d2cd917f2dca3990214ddb4a90cea6938a34739fa0a76d177692c1f2d917771780331a1fae2342ed0aea89479ce8e0545e6ddaf0ee157673d38c7c5a78e126a84fed904d2547d2c33865a5cb97c3c04b7f540c4b9d71486f501b023283890d4ca8e92469a4dddd9131c6a8ecd56b3525ea57f22c4c3d46a1d6616f930af32c3b9a6a9288d5218af33a5a7016a417d256b116eb85a51b6e0363afe13aac86ad03ca95b465a851c7581dff45505fbafb435b556a3951671bc216c1d85a586e08a3176fd7c720d96875008684358131a8447bc3d687d99fa6fc46df752cef0150cef08998c7e537d52ef0eca2ca8c4178dbe7d36a409659a3136508ea50aadd1f62badc506428ec81b82c1d75267e023a782df1526e0c61b801df2407d872f4e50f1a423aeba2e7b350a14227241b769c39cb101ea6e07910f831b2d63675e6901f98830d7f1f69efa3de28e6831ae949dd3d3b1459b4db8e4a6b09080a0662b77354cecd68ac7743ce980dedfb469817704064124482fa77ba071821052f8f8e6db56055afd5cd9546c63e41c53a8f8df5b239d1814a84e6116a124db0b2c49006c31bdf2b4d3bba4e14a82fcaa4eeb4d1061bb8d2697403500e48d6d6e4e5d94d5261474af559f2b748179cf8f69d06a176012777a81b78743f1209c300a3f03322ba5d6f9a50f67ffb97f59afb09a2290388439ce71b03c0db1ce3d879fb089e4f374a5c58bbdf1042dc283c7fcca7d8b62195719c03f7a9980509bc5248e0446a4ff26fe82154ba758cf575d7afc64e1a180ec358253579ace43af160cb5368a3befafbf77217e86825f05513a0e2b0a85d47f735735888ff34596006e86e2a9583e70e23d8e90e3c6aebdd1b9e76ca63c496a65ed1c7de4d8a51ceeecdb8f6a2370cb8ced6f9588f2d9c6ababecee519137103d3f0fe8c393157bf2c52b8784ba68792c6cc522dff51c953b997e94b8053a1152b76c0aea48355da6c8f3ba6dd53a85f71545a12707976d762e6a150b465d1b612d8c1d57323684009fe3d40e62dcd16856113fe9d45bf8f44b22418d8a221ed8bbaa524c562f89e8be79182dc5dc44aea91a737b930aaef5814a2d0ca4de875a816f5f7aaf0794b625330a4d4ed920ba04363efea92262e9cdffc044eb5809d748c959bdcbc5de1b6d49557c202a4d5be8116897534bb47d1dabb97672d8a2eec18694c56786d89bcedbf226113fb3bce40403485df07d5f7ac1e204d4338689daced602b23ae209312dd140737487863683def00bb0b18e4c68d45d0b92b41ab4e6d95cc7f628e5d394b44e5f43197761d80120aa534baf9de300ffac2ff96eb8ad0a8117dfb084b5a2f7b839006d5c54243c3e0d30bd6624e05a71f63e2f6d61d375853cb0e4fd53afc0e343f749ddd5db9826d12820997fbf290ec1be3413e53608b8ee39c5db8a235cde10b84436b0c8a07a204e4c6dd4bd4fa2223799bd7e453ae460053f4132a0cf6b2ce2bc62d32a3f1c5858c9bd7293c4cffd342266751dbc91de382d2c9bda7601dc1e0e4537fdc211b22617c7ec0a1ac7045a583da3b450ee7fd70634deba92ac0439c50323c86309bbcc270ed03d64ec5e7c28e3d8ea8b145b2011da85d417eed8001b61d1095dccc63244b6dd617b5fddb968f38c28e9398f5c973fc4fb4ca4bc0c04b3988273b1d284a011377d10a4a1f0d21d7439c5db744e768cd4a01498bb8a621fb1ae57aeef2eedf49078a383de455490afb7e60322caf2b2681d0a9c2fd502a58d8c902a52bab1ac5c63e6ddfb708d6638638c609cf479b82ef889979b7e4a79b78d03e2ee16b2b73ab405cf61b4627c3efcb60aac72bc774f776cf24a8bb24036983bf9ee8251c656308d9f6d404882edd5b81db0bb14a55477df8811eddc245a4a331a5c1e8b61c15105732a8bd3d52698cfb9bfb98a7a47ee821d8f880bfeae3ad46c841e503ad61037e8bc65c99f6b3ddd1129dc0c32281d5d6ddde4eb5bf314e4c066fd088721d82863196ead400290f7adf55056cd129466af505496f75e560403f74cd1dc7b3c709b139a4a68f8976cca4d6441ab21011fef1d17916e9eaed080d776781ce77ac09d211213caf30f7f6d7afe6c28a9abe1b505c846383e270328f9644fad525983a797aea6bcb2a27a53c40d030b4e96eb749d89abb17d3030ceab61fb69f1e0a0d7d0b9e62fbc9a3a4def2c25ac19681f75691d81438e12519cafbfdccded1db9cfe986455e482d23e4901e8c9d21726cec59cf4f4e2233359b466ed9c87c1f01e6d253607d02dd0d082f7e219a5e47a6fc2c7be04b0436e13e3d23f2e2735ff9e6d062bcf4613cccba22ba3027391f79b1f97a79dd38a5811b1ba2b74d8b0404ffee70efc5a2f653628c12e58e3afd8053bbbdfc7c8851c9e937e43a7ca52f2d9520b3bec95f7dd174236749ce2d5883cdd84f7acc5d58b5a30d562f46938e07f9a0af887608be400a569942202be4f92db423b4bf450cfb3aeba07007c58232d8029d32f0c3a13b71e393651c0c340998675800a4e7fdce39d306492520068ee2a941abea502d50ce33602e9647216d3be7505cb06f14975bb903549e38e57bc31a5abea17e03e40e3dc73ef396f30b16dc05fea2fc94376190f6cbbbe43c89980e951442496a99ff611fa3d93693f73fe1907f63088611e0111a5436f03dc8b79ec6702dd2b5b4c7102da2a94246cc20a52627c668f02e6aea5df55403f1f2105d728edb6eed277fe77e949dac995bb9264e64e4e0928f76b99f34e595e6416c1acfaa2997b8b16478d7b7581925430dca918545b3ab3af338b9d8bae2096d386263c2caeabd603731f24ba1c8c4ea15b894cfc282b48958530fbaba4c20ef41030dceef11737482f0f3a7e4a7ccb59d6748013efa1cd053a42ca51c78dbe3464816653961c55b069ed2e26a5dafcdadaa1484c98c67320cd803f6f1a0476e7dab9163316a41d4686f4e5ad97dcde1db11e9a38e4810f681522679907a8fc3ab2537268656778592ae2ccf18ed5e174484480920c498b952d9db582fbac6856a8b6b5a3b12686904a782b475f281646543fa38820319ec9386b5426b03a4c05ced4ec5781416e95256fec8f67e717771aa043c59dd8c457e208e2c9240003c36ba118a9f19e39a28b9101c631cf346e1371e2cc8909b6fc530c12abc0b461bffdd19119356a94c80edff3ad42fe563be0ad1473d95cc67ff01f3aee6d7b5d552d7adbe34f507b931fe0470b276245e89cb8aa79b3501db472c99305e906a24103f225ec25a3e39ce59210b64a8f3d4b07adb39ccba97abad6c6b553ea255590227870d1b3ab6eaa3b1b83b52727b4b6ac8ad76214a2e8597414ad03be06a4ca9ee8a67bc230e74cf536c2c3c7e5026ea28176534f6b7adb56a5ad2bbb13431a17e40da11b599838c1eda125562ff16b74a97f1e7f05654e12cea77f4de203c08cf8c686c5e5e4dc94301e8a7b916fd9c4d60c9eb04b57bcfdcd75d42b1256ecf982ec359831bcbc4c46ff7718b63dbe464c32be8a61997f2e52b62da266c226588f6b56b75fd7d755a65a339762206d218ca56598537177c2adf27c092be14939db5f453788b20d8339b84111922e579bb53be64296bd5ea677d4f5587e125508214948cae6c9f276166a66c3baf989389507abbfe09f8728b68c5caaded977b03e32785f42255db0a3f6e55c4562dc11bf69d920cb991c3b98f15d844e2a13fc70c284e09ca5892f372752351b696f9744203e6586cc6f1ff11a9c0d80643fe299856815e00ea044cca8e276acd60de2e727303c38d2f33bfcb9a1b1d2386283ea78e44912b6422304573c1bf226ea77dec62c88ef30318597c38f21838b8cb71415969cac7c8ce6fd592d4d8a6238781efd8dd948acd8a93efcb09a791012644c261abe637aa7a3094fcf9b396c8fba3f3b2a7f04092da006fdfb9fbf61972a5a0f99ced7aab5c93892403a89c48705fa753ce82640a86bb409c0b08c2f200c0317c6805592e8de16cfe7c23934b6ca2a90e70aa6ce5f511c7c50ea9132407f01b3e6eb39b2dfd39f3ca97042445964a6cd12ff473fa418a9ca18af229e0ef5bc6166688c64806348277eba6b2a0a282cc6d08265a86ca63fea95b51f0ce862a205f616168a77db8ac384a9481c7310a3d863d95973b584940f8b1fdae1c1cb504806517db88de2e14e94c59a458060b1ad7a2a37e129192e42ce6fe0f750dc3c42c741912d26d99532f0e4e0370dc394e6ae67ed77c73aaafc28ef100e79f72c51995292077e56586056db18308c7b36fb462e488182a3c2d534c534fae05b65068341745bc02b815f06b086214307662ea38d4e4e7c404ce7b0c89a19210691e3e12d817735b420ff59d51f44eb5ddf699e3e9150e9711f94cc78745ec15de8605f3fc97e582e95ff60829b3b925bea6a35d9bd4bdc121ad722c5eff881ff9c8458772c8c10cb50863822161d3104d43a5661f2aa7740fd3db578370dd918d450947c5bf7c64d5201da0da1d37740114601de39801ea1200b81244eb2bd6078c331ce49ba27a82b3f05d9355ddffa9416b53a991ac17f1cc97555bba2af8ac25d2219144ce95d63b8878f6fd0ec8d5ad6f110f4d33212758eed0170eaed49ed5780240759ab83453ef172dff7a145aa899e879d84cd6c7255866d8b7ed87ceb3e6772c6210f3a745a940471d28317f7f66542978853ea8014b0f91a6c300d98328a95806e5ad7c9f6a092903a668f73365467d10deae7ce4336ea2de5db97b2b9e5239ee859d7457819785b1b184ee82b036949cf9037d6c0f320f154f469b34443b1dff58ffacdf5909039618465b96dfd4d277c1c56113e47cf66fe65d5177c98cdc752fda9c90f4cfd361da90678d834391831d96d5f90e0a0329541548b416665d20848eae33f80e91c54d7914fce8445179cf163320e97fd8623d92dfa1a70b570806fb64ad0c593b5d8cc5b42fe103b89f368200aa122e7ed5b7556533b7e3651ee7bbc207572e9b6cab52273b3e01b36b350cccff20e5f9b830f6bbfa5734a33cb401dbb6e5dbf138b84850c05a19f07c4d78f56b27b03b3716a024dcdb2e6166585620c360f3056ace92b56691f617da4e3f5150b2f64f6f94e7c9e2ac4cd4fd4dfe1646c04916cb34b1b7f6ce4c7a52f81a52dbbe8984e2a888869ce82f5e9af5ca79475e685eb6fc796f3a2dcaaa32f19c0b6b2ea3baef977bab2ccadca2c67f8416f6962df609b0e53a1375ced81049a353ba6f7872f943ba4e3a607fd62c792d51c7c5973f7f8a30d624fb0e2a1d985b1ca280563db8f28d4eb6226e61eacf4e70ad4b09d969972c2186270f99fdc2845ea54de3936ea4f6e77357af1e34856f737a0480d196b1d91bc2f61764f1af25b69ee5a8a4091374e27c03b00233bc51288db54fd47a6d191954754a817399ff48b5fef3e5a6a1f1cefd3014f085dd007c635cdc5545f174cc1d43b6019bda7bd417d2b58dd29005f85469ee897d1407f3103ed4bdd4dab3488e1ed7a0c3a3618c5749260502abc010c721ac6199c217a2b1ba5205b9524ec9d041a132b4278bce394e3bb02a7a1277f1e8edc1c485d9b7f02bc6fbdaf0c75f59a03dd758b4d7a4a916410375d3ac4bc81de16c19edd73966b83815f260d6365468e72fc5aa71bb92675001d81f96850ca5468ad5d71262dad550a3dfbd9e3968251437e66832bddb09a80fb1021536f098edbec6620044c7fbdcd26ab363132bcc877035e774d01a343005687e9c43a1ae708b96d8f9f9fdd5fa5fddc3983abd88fb5521e2a7b6366bb0a1901427c394d0818b8fd4acbac60f3ca30ff0125978a6b319916896053c4cf62e0489820589bd44219e76c59df1012d5ef48dc07248e404c2f02ed9c82e8cc620b8bec678b97178017ef162b9535cc4a8a55005b7c9d3c6d45f6ad979c7c373b50f5ac74765c9e9f6840d1dd2214600bb61e957fd56bf6784c69284c3c449b6d4aa094c465c2be2b7a178adbb30d123daeee9290844f772138d0d7aab9028992860435cc0cb4ca6bdf0fc500a5ce84b67c31c103ea085fa5398dbf41ad276bdaf48636b452c86918154a1350e784d3201b93fac6eea0b2fd2c0abb9f2c87f385aa85cb8980d7ca9bd515b0ea905c9377a1127b9ed8151ca526323e5dabcdb55b9669466cbad3d603be1b168594970c071a3d12d11fcb933fbb14a30e86ec2a4c62794b865f015c472a472f0390782932aaefbb2b3195b88bd664bbf716d82fce7d0ff121ec66eb4cc9db7d3417be23e407bbade9947d94316466419004138570dbeacff63cfd20bb675f59b736875ff41bee3ed452bf1f7eb5ef65a059d34b9f1631bef90672a7d8b4ff86f7b222b9969f638ab0052f770c6d7b8008dd55b0411fde6678b4959f989711bf2bd206d681a15b88e1bea8e14d387409d462c639305debe365ac9e15eba8dece51246a3bc1d798d7807aca77910e678bfac1966519ee888a174a54355ea6c1794de38f067817b62d2ad565779bc2981f44da6dfd365ad3eaa92b954d2ccdeb4dd6bed72831a401df18e74ed89ec913fe7d653de63f36e5b3c72ff7101510e88eeb8f662b1e15e0bcf812ca1f18aa32a8047d72755315889b7085a3785f20be3f917e61eaa320dc109bce2a1aaa3e169268124191f9bb9655d705a729d24e70113642c506599f06d6b03b78665abf75d6484e30173d17da918c092f0d861a25d07e0cc293bb1f4b075c20acf01e1f301b505d1b572e5ce0a32fee12fe3cf7796d6f108917291dbeb2dfd455c646fb9ef6bb071fe34b24f31c698063bb70e6745e1fc4217829808400566a3a1122aec701358c7734d4b18aa3f2113ba7b88363d9dad38a8da7a76b48550438f50da999ef95a5c7d4c24c37236c7f6b3a4061faef3fa1bd5c3d50bdc4e63e75ba128094c4e5ff93933b87ad9e3f6bbc257c4cf4a7725cf79adc2d41e7f82478818751b1f34bb646113a6e651dfeff457cafaf2be10494b67bf14e7d6c0b21f5979a2fa764976360af47cd6a07433761cc81460fafc72a7d0e4f0228caaf90f999dbbeb6aabf4859fb0dc3b06a571332cb7d69f48f66970ee65e78f295a4dcdec2a45ee21c4fc0fc84318503c3ef6ef38d0c6103f3b150cdc0df98d8adb8a00a0efd0a03cea5e68782d983ff40249a90b733f01a95be441e2c4d3db8b9e4b5e8bfc252f28fe27e5a131e95b4853a22bf483a7351c3638272e2e09ce8bf56e5d82e66b71c6b132a14cba8bf6760e6378ac0d1a4ff81f970f390b2382b37165bd01d9ba9e8b92497adbb7c16e79f3b4105e66a66e1db09f2b608a86f87ee9717054eff4003958597552b62d7a726fe4fa5f156332635cb05f33bef3285ea037c1c11457c20d9324f880c0f15bdfc0a6b03631dfea270855b98ed1ee87ea7178a7eec3ef6bd607177bd00f9471645c3d3499bdcd9346c5dafd7110af282422c68cd8d9ae9fc3b56a35ee63ff9ea12a82ef0748c3552dc6236c6963a21d15950aaa492272c6186222820f8a4d3f7a7687382c27176e2ee09e83a89e2dfa78d5774ae7870140ca3a16601dc2404c5b7190f1e0ee587d22b20947868e195a2542695088b944ed177377847d8117e5333b55ba4ebe5e3490dd17a825b3fa8a5bbf2eb9a45778053d85099b175b249c280ddda99df1b9b16c3b4657f727ce5c08fec53f43af0aa2a41e1f2c088494e9d20aa9200d056520a7a94924a57bdf299d4411cb4d39c00d471fe53eaad8f39a317c5d8718c3d78df68b103969e9bb9346be563c0e53c3c34f2a9aa469b3105ed84d2281a482c49ba5acb08318adf4ff90faaf4639e8bd325be7062f98e631056f30c917081735215e4b1ebcdffd07a4bbba4ce2e4f02ef04f90335e953a82b1d41f730848ae056349d6a14284c0fa2fe0400c74dbd97e3db77d55eb3b46f31ce6bd731089981d21d8532ded462be8805c78b846e6fa31f27a28aadc01beff0c235b823541f34e272b8f79243fd4c583edf9659d35c0d1a7208fb15827b28fe75af067b5ca32e7072dde8e7a92e2bcf4ef1bc32d13c3b7c823c425426aa3e4bc34d1062a040a0166b8194447338fad37013b3e01f0c8ca417cc88c193269bd3dc2f6b2e46dc2508b108076452e3b723d2f91306dfc2b31b25ebcd52d7c755a0a20332269e2dd7d96ca33a99f02fe49a274b354e4f92db8578380fe2cc2548b204658074f5c34607623e79d918c69dc6da859e65d675a2e4eefa29938820f156412f9b721806ad80dbfc7eac90f54176ee550d624b5c7a40da3b76bb7fa44066b1e3a7559d83e35800f8f0f8d26098aaec8ac5fb5d4352502a0badea745d06a6240bb540a38ed22f3aa6c414d42cf65fbe51c564088d5ccb06bf91839065fca2ead0a7414bd7926e8ef091398c2b77737cbe9b0bbb5ec4ae99d8ef2e7a6cf93615f369e4777f392a9255688797854c7a486d94ce666190a0f74db74448c69bd22cb677b096380f00bbef37f43e7dd72cf5b0a92e8fe77525d6ee72dc86fd6c9192799ab73ad044a5f87c77d122251cbeaa700211484323e765505935fba8d05ab44f3d5a2197d1b0dbf464bb54f68b09c8dee78ed225f6de6b20a7da6bbbd14fbe6fb2b6bd5fc69ab899f2422a7e33d415c0dcd8b5739f21335753d07e440bdbdec92dcdb3587aa54775a30124d1cf3695bf3aa0189b18e019225e38df62fe18136550e8d8a861d324c187c58f3b1e96939d48ed674ef05dc4af0dd59b6bdc6f4788a526c3760141984bf369b298d7de4ae06e422ac7055d9c9a52c2984cb8a10ef40f61a7baee3b85f3bb6d8970229bf4fda3184ccdf7367df59dcbf1de5b6a044c9ca702299f45f100afcc9e93219342353838befd8143a1470c5200c05d45517d1bc384c01172b8dc44441690f8662ded1e8796e9500d091ad0881a087059c44e38cab2f0e9a66f3c958cac2f179b6efbda0264bb596731954daa219d57f34b8d60baf72d268e3b221c04ae8edf3cc38d88e0e2304ad2e2292f0f3e3c3382c35580f613fb11c2d887545f9db5502d290eead29a50f907ab41cd8ff041f031fa1ece097ebc22c3b6339e3bdd24962bf5342292273b8279159d6900696111633be47b6b83b8b6cb2755632a0d6ed3d23fd28cf64a88cbebd13819765a986c3ec8e10f54eb44d76de5d8349adc946dc8927c83bf65113d3b4aa950b991bb397a2a9f88ec8aad15578418ac2829f33701562f04e340fb47c2fb56caeea7003d0d4ac29cce3073e329eb415abf3ab734cbbbc668836efe6c3a5f48ebcf3596f84bb6df0f9bb0749dbab9fac3ce029618e20b206d8780850a8665fdfa3b9ff84f03a89c54ceeded8eed3cbfb33e84c7286d2d0352b41012cd8dd8de026b60c10664d707ca9db7a432616f271637347465197a6749cb4b1bdf31758f3ad67893d6e951637880aac8b1c2466ced16174247595ee4266707a723b5e606a5943740167682c04c89d3f9da348f51271a875cfd0c55e0a158bf8e87c3a3d88232ea12efa673d8b05f9ddf0239e8509d329e2dbd7d9a67429e8cf3d15d7ad6d5e59423758008c6c0d9522dcb52c3327b26bb6f41a30345e69934ff08ff2520fa8220b666d2e8177c71c3a67ad8012f52cc5645df257e31dcd9f0e2eb4a857e433e503b243baf52fe7c260a74c919a52efc27a728b4bb40df5e859e009b21d9a861267aba92c1fe6f8822693c94896d724d95b5d3ad6cf90211f885a0973d099b69e6d42e9db2885f69c26b1e84a87a23c87e289162c2db29a4297a3edfd59613102b26ebb70b2e8999374f5e4ded1fc8df574b776c1e130030b8c96a4b6af4fcf4a33ee2dc629f93f1229c739ee6ca10a0eac58dbd86befea5a9078856330bbb00c94f2c9bf0282f3c02a8c92b6be26d560e112cb23f6c550fc5cef30e68a86224427e4150a1a05524d49936f37b53adade2b30855ae5d27b6982af7ef05e8fc2cbcf44d12976c1dd10cf6e493327673e9ae0c7651c7c792082577abfba27d1305837af29a95c0fbe3fd300025d9546974e9d9872a0cac0d76c7c38d0fdc0dbab05217c5446eb33d1995412c84ad9fc385455d8c160f758e58f19bd33b1352e4c9421fbe828bc1866fad54107a296cf7872ab97cd34e64d297461cf8d86649b58a9982ab7e8ffe12bcc5055b76e9217de29e6c9693d33349647659483fd967ab7a9be63fa205f2a75820d0d55b4ba85efb129797edae09512d40eb6c853cef323ff61259ee392befa6030f5c55de6a42614fe552f921ddfcd608b427a8acc321b1a75b8e50cd94078602f419e8a2afa95e978f1cf5c60c78d6015cdf950134b084eb39a9f4d5c3638374aa7741900157e4a7b89bd355602d29200cca11682b833d10da77ae2a9674fe0ae5d4c69333d290312b7a09f31a3845bbb24acc62fc66a2709acf742fb5dd8aff502320e3dbea2faefd2585f11b26487246af1c0e2fb257d827049e60398875af5023609c475cba429f4f2adbdf78c6e3c105d63a9cbc9443f0b06927dd0d8c10eecd225c2cbe109639e69da1d057689d6262cf32f50b799afbe9cd11a152dfb1cac591a546313296915c27abbb3335c0d85b3723b3e8331df51d6826360e7832fe724542f86b7778db44d9d641d0973818da7bea05c90fa0a8730d25336da7b57a6f91df45cc25d2aaf473ad1989daae11f09efe300b2ba8577c305dee3659b73fb038e2353b02c73522c63637703850ab8548c499262fc7879c74b1e75ebdf8fc055b40375ec5f71681a9473b2d01e82e5520617786b507c497d56ce6088c89811f5eccf90fdf2b151e0927b2218d1c2270239f32d5131ed6fe1e92fbd62447c4a87816623b84221e5833a096fddb9505f4c491138b4045b62e20ee20899ed959b2762ff4edee0d5751a813d94dc248d21eaa3ecd8f7709184565fa00b2875c6de22f63d2d5e1c752b5f2f1ddb00fdee4ab8bbabdde445991a07d2dc15cb10f12740b2ad2afedf13cc45fd871055f6321b33f1b0d61ab44ad9cf2055bce60222ebb1b41ea430c19595bae8594bc6345482873995b7b0bd07c3592601f639234e295dbf766301a3b098d5598e3673065f8839f90c86cbe4935beb08c463e2117afd1ce131b9f0e3e5126458abb1a61eec655718d313e552106706f556a1fdd4b16ef6d9696801cd62b788683a29329fb28fc9b18439fa873a8cfc1d1cf921d9df08db023144c63e0a1a446050aa8f28a4c0999efffd4901122c747ec5aa264e606e7c39110d42e3254d926f86ccbefdd9d06798c8e66f2400c8d8c6e6b5802583f8482b8011d46ded0aced631ec6931c035312940b032241c6678aac331fd83f42a8a8aa91a3bb977e19c577a81c37d66b7fa1b585de984448aaa8c98ea754247eb1a85086d335cec2865c17eaeac6ee73579177185dee62592be421423650d5495346326dbc57372f8386b68f4c9dd9255e0ac3d3fb09c57a5e60061a6c40322792abfc0c72c5dbe1814047755d3e0623e471cff2a341902dcd6140fd982caf99a47bb8d911467ee69c06c2bf8eafd53878895ef03ba3f2ff9192c31cbdd8eefe607c775f04988133819d0eb24e424d089f43a449c73b9e8ec4d6c7fe1e61025e2206c3191c2592efbe724dc8dc5cffff260caf8d52bdbbca4fe1db0afab267507c45ff91e4b99274bcb0012e371f7d534acd815e024f1e5abc96c0b555859a47d917106ccefcab06850a941199ac5c88f175bd66d137ae35780ffa7886bf78c03e74d35bcb58c11856a19750b214edf8dd37ea08559156ed6f4f392f460853f187bfcd558052ff1d513be7439b03794cd5bf5d3043d196ddfee2c65d800530c24d9e77e92555d786ef3b8a56c841a65613ab59e71569dd7432bf48abd44bc6fd3e1a2c74ce72b12a106bb1e4be8c6150130ecacf97a6a9e395a109ce5f96f690d2fc83434653963c49afdc033f4b4c9b650fabe52fe99b6704e8774bfe70a812fc4bcdab9b2a90fe92e4e9bb9a037d54bfb630790a1f1e92a8ca8f4a806d553c9159ca478ccae3a1042b7789fda97403d6bdc4f4a9d86dd83a4953f95d39e30b9c5b72ddb682bd2aa82c3c7cb0e66d169ae561c7c805e571e7c773d4ccfa896cb0ff28525d299a500d14cb15717d7a2ca03e865907f7e898c954914d7089fc1b4392405ddb46c68b3479e1695725be632f5c211dfebaa856a68bb8f4196a60948a042ec9f54f18548b0e1e09e1ab04a2bdd9e50a43f3c44f950a13d4f51f65b20039f6635a3666ce4cd61fcc4e7fa863fdfac63443f80c402a7ffb512b95658c1e7f2cfcc83c628844fe2ca8ecec1952650ffa7b6f0e064868ef013db7972b6401dbc6d0e2357afdef66ebafe8dc60750c06ea64855d49402108f2a5df1fdc245187f4eae4610dd83f5a9c4c79afa23dff10e30c6201598241c7c57bc648854f3707282169027c5d0c8cf4a20a61bc923faf9dffffd181a84469f04931725e1ce0cdb5de392d669138847035e2788b5de783297831f78b9c8746cf7e282156780d06f898e6512a28942b5e30db2ca5c95256c2f2a1854aceda066936da5458f41fa9f0dc164e28f022105c1bbdb9e7c1876ad89033e97474671180393bf280ec320054364da70c77b77fe61aa651a41de0f64b3a055beaefe29574eff8ce5295c614c6f73726bb140e2a63c1b6c6a24f284bd0b3ca446048003be044230734817e90f072cde6b4b297a46a31c0cbcdd5fd09bacc63eea24a519bdcbb7dcbec0a203a276b9df6744bb14e899bad5c35194e065f5dfdc3b22f680e4795c374c81d9a71eeb386b2e2613d6c3ad138149dd3b46554d767a1347886aa867e09f1c9397d9eb9b5e425e14748ef8085e65a513ea423f738f02ce152b5ecbd51dab8d85430020ea7b7182a660c9200099bb4aa776d0534506955077335ce7a06a062e28bc61878d77944e5f02d93a9169f4672516544ac18501999c5ade97bd3bb39719123b5f62d28aa4a76eeed8de5553ba04ffecd1cdc51abc664e4c5bb0794b79d658578e5731c7966d01905f176348afa72e1479fad381e4fd28e32ccf179db4510178398e531efd06e5a7d2e14e295ff84cccada4e6296d0ccd2b003b462307c553e314c376d25299d2ae51c23920c583165de11e99f261be59b2db9587a326385483b40757cba4a3b7b89363a31a02d21d0913219a74bd953e49cc97b623d00accafdc9504e3b3f653abc5448f39dadd2c2066745ddd08c47b429e359c723762ca8947399b5cd856f02d30a0c4a726ea6a604b33cd44c71b6a58a42117b752b28abb3786dda96e27e1589eb05ec5b70064fa7b9d219b7c4da7b2dcce1af5e73832255724bbc5aa0d36ae1c363185869b8a1b878f68c243297feef226303d035e3b0ad02da08310994989491abffd5850641c364f54557eafd81b98c34b8b211a20ab812d65365ecb4592838a3f1884ff024365d896d7eab2687a5a8127aa42f703898771a34832d7a62de6c8f88ecdf09ee2d40ba51d165212e351a95867c6bd1814fcf01cf51102ccdb14399ede148db238385ad60d5e00ddb6d92a55417be1f0cbf766309294e66d204ff8e6bd8d499b272de9db26061c6832d132045583ce0a3e6b44555ee78bfcf8e9bd5f3de798e015a46fd0723d5fa0d59f0e8a02a6e9eedf877d9dae0292786f4c490ed9f946afd9906fa10a5858bd34da9754840881e23b564b821663f5d782309d5e40d8f75a24e69fa349f251d5b77fa7cb225f353156b58b9cd83c6ae1f47bd035a73b8d5caad874d4609f9bcd73041997520efcd9ec886c4fe460677494eada53e080d2f17957c59308a18a18d2305ce2513216740d5f1a08bfbc1009ce1e2b34e3bbcfcc6bb165c6259585eb2c819d61fd110499f1c1b8e29de7ab97c65e8da6bc773834fbb83bed23f5ec431ab378c45eff7127266fb9a5e29feae892e61b9ddf51c78431a138be01a26898aacd3aadf6705a41a8ca3cba5dde24e522bec930a455e646003842403e077e00a1077d8dd7470d128569b526b06b71f3b43bc8310211b37b57e4ec4f35c470bee1e7fc9d38267977e7520e49ff6f25224a3f28e60541110902487468c2f9cf4b48f8e7d84249400a0d8cdffd1e30f9e9477d50590e8b72d55c16dca12b7ee5c63ef87cadd5adbb07ee940eab804bd3cdcde24eada4ae0a2c30a4c2f229efe488a02f73ad791b59fc306472a8e86dba37925b21f3e321e15bcf4c1b32385fd00bdb0753640da1dc0d8cc881cde1da90f7da7096a3dc0d1a26cebc50016d24d1bbf2c771079606c349966b1155b195c319176874c6e973afa804064290918428afb0cd3e673482b1cbf558a64f75fe53b9ca3327ffb3de191d5b5e40dfbaea748a07c3bb51114233bc50ca4053ebc4a255a82521d3b49dd6243e51119a8ff720daf19f70df1d3df0f8b35ba9cb22adb96e6f70bc996e5e71fae4a7f238be0d323587766f11c650810fc6abedda239cce23f245e32ac84bd460b06b5f2417b632460d6221de887c2b2cddb7c210abad5930cded212cc7d9080b5a2c8a63cb1b3056754f3808cdb5a927d77eddd8bf33c8f0e232eb1f1754ddc7bdba0cb7674469e50d980e399007a92b4ac27e8af014286c315f2594014e63a37707d5dc0cda420fb23ff2654b08f7b53e4ef7991bea647a5577ba2edac7dff7d8650e6a3a04e86a486573ff1dae971516d71f9dc44e55a04d85fac43a1b2d0010c30bb99011a6c6acd69f870205861b7f83b0b87cce5699cd1f8420a2585e768bfbd5acb73d0228b28d706c917f0468f4e50e272d80717937c8e95fd03e3b1c5d6d8beb1d4a6fbbad1be3d872323485c567f72f4e55b275c30d86fe3ccca5172fcd079008f05781496761fd0180861429d403a778e8e705f2856a43c803b27c66a1641943189ad04ba182e282434851de002b38064b011cd63b6d3aafdaef48fdb5952eb4c4dd2bea360922b837df9b30c292c05e4ef3a35f73f221d581d32727ae624067265d565554e63f51c8a89c4d21152939ad6c656b6b08beac3be4a4a18c5d11b13877f06a7115e3cb2ad7dc8e1868a7a55747a69591faecab454734ea77a23eeff25209a0b7b6b9af6531d806f56acfa6484869500234bafb348e4500b0b34e1d868a6c22b5250276e48ea6b891f01808e9b566b54ab7757e64381b12ca0c3ea2c0c2c5d5b625489ec412051bb9b0afcf79e8853471cc70d6feedd2dfbbd6511b08b22d6edf5995ff36aabd959edd9c46c39aedd99c6cd5cdc740a924e49e6ae9022d657a5fef4d42ac894d706f0d17f93dad3ad78b0f7123884a8f304ea97a8675b76bce865f2873463fbf56e1ee72dea3991e100f8e3011fec48a15d08d67c486974e8f16a294fd6138e593dbcd4454cbbcadcef8c7b23e3c72180d2ed6899af93536481f9bf7844d9d5d45a6dd808dc1efa46051b6acc211d81c1e919fc5aee3286c67659a7add1a04ce131f10ee8f5d4422805d3262f32bb9330b4989dfb2c1dba6a44e242bdb242dd34b16e6595347623c1a41ede9375581414aba5be819a39708cbfebb380014e7c00e22601bf29794e32be0f94de9987fa4f6c8f7126157c5801c79cb324c8537a89a135e8ecdf01f81a90eaebb8adda1174b85144e5356982d5d5c7f5cd4f79dc5f8aa0302b334cae21982b5ac8206de320e224c74fae7062cdd1accd7a0de0496a3f5680db3e6680ea94d9dadbb03758bec68e31f71cf20c59675b82531a3b79b0a10c5f7b5ac6eb9734a5e5a1c01ac9bb031efdf91d53ca7ce6e26018314d749a0beb6b3ed977cc5ad84f8ac222976540a24537a16e7ddf8cec89bb9470525b55e40a0d05e8dae5476efca5633b31b302f5cfe9a30a91feba38f3f9b099681ae317082c0591f39a8279d28cc52763e8525aafbaffef50c3fe216067445b248022a99506c4dc0739a0823183ab4ff5693553eddd129c29244c90cecea0cfa8760705f12e947e2b55e0f56afb66c9d120fffa47833149eb8ae6303a0bd22b30d034ab690ead1341aa5d54a974ee7c0f51ac648deed44fb3ed8f8b402201be0832247384c17ce7a784e92c1cfed4258e2f012e739e342397c54572ff8dabd10148115519c2bd9c49ba52080811a6d0c0044c12ccb9043b0cffa22d7928533072bce5e4ad0418084839c58b37dc47b274cf26a3b56399b89d8aaa4f76601b1d14bbe184699a142028700218be40377afedf2f9cdca6f8046c7fa5774106200a0dd278d6cbdc288c29161d76d93bcfcd48ddfb2e86840ce756111b5bc485cbd774297c2e1d4c1e1290cf63f52982b67272870eea61ec3b16e079d458878afc7d155e9480b80c2136eb6162a4abfd78423b0d507e8d30559817fae202a2fbf8101b7ff0a7dc1c37dd5d20584ade5d0c4159801e589e325e7c25e4e339225ac28e9b6dd742735eea40bd31c289b9e2e7fdcd99629258d67baab4eef63af9c72e591c2b5c70173ee208be496d3e1764d27c2830aa2d80653a593bf41843cd178aa83f3cecc4d2dbc5a078abb4180f698886d60890c84d4a9f24b2eb8b14d85b78a079c4e54839984e8b5a56c29f06d91bcb34ebe217cf323c4b5d25b1aae54b59530d9d07db2b7057d8ce999a95148365c8120396d42a7bcf305b859b4f274955e99bbe3f9576d96ee4ee15b6487a3633ee03934fe8dcf0849b6e1b0844d5a5a9e970281f4d817841032b142adee4bbc984ba1a2de8091b5d314721d58d84b5d9407bc7fd4dcbde2b0c6b379be7b89a7dd31fe4c43daad6f32f684f6a1204b9ee879ae3204db74d341823b4f3381397b0977e7feca68317dca021de393443be5d2c42efe5e27c785027a8f28f7e88ae52f0a3989e9d602183c8dfe4859f70005e12e610baca9a8171ab2f7a353e78f703099d6e9113dd6d077a6ffecef0cd64726845b5fe101c2831bb84565cfcd189f06d145b4bf36390a36ca29faab30831c0ce698984c46d706d908f8fb9f304fd17ff33701d0d28d2d51e9a3bf9c5c31fc4d047cba2152973ce693cb03d3d1764128e378027359fc3be728dbe132d58d6cb5e263ee61f515191e9ae6fba5f3519d38808ca483a28b8867558f4d37ff2d10457a776805c196df35ecf2e714964121231152e0464280d91df66f652c9f898e99e1412d1ce62ce5d4a3490e161d7f89b97b8886ad6f4abf2d911071a5f2f042a88e74a1c1a560e8a7d9e76fc7242c7954ed77392ec1525a4a1f616ce4c2d08efda2d57a5f2d05b32d05a704d30f920ca0df44b698b7f0278ca0f8c5fef643fdca46d0342063fd27a2712b63307ac6d61eabe3f7007841481ed0a7ca9efbfe667d1ac8b81ead75fac4de12362bcf6288b19676966bcb98066a56ece795bf9fab0200a963217a600c2e440d991f16db77655ae61eacca0de2bf53f6319e9acb6d1e7221d56a61f05816731c66bac574c3531dda88f446a5df78784b45a480bc5a8131584aebdeba22c2ae013ff6b59a0a6eb1a5c3a5f06faebdfaaf618dc6ee308ae883628f8dc284f3afc58815377ede9843e7eaa4b0fa4f357c045cbf44ffb592b328bf18309ab1354275bb73b4cabf25cdb0eddc0dd481c28ab9fbbf728a44d394fa6bb0081a92269040d9967a775bc22796035bbd310a1eb1e119d58b600c88167da476ccbd556dc9fab65dc81752c6aaae403bf9e238c2e93205a21a937fcec60521bfdd057e205e7595c529d425256f136095d46d5eb069e93c9535fa879225936c332cd3f5d4dd15e05242e965041d721692c3efdd81e1b3ce5322fb23732fea071e301919b3a7ef4f732fb85ef82b29a474b01c33775f6e1c94644b4464a7e26d331604a7bf7dc1733f91f11b489f37db7e1fc4220bde9896f7e522fbd3991a4c1aacb0178becf0b8da8a5ed637268e24e137635fda6ccf9b99b4f003330801f1d7aab9c5acf125040bf9f0aa15c00bcb814a9ffac96e968e67403fca2468517a7444f0fdf0d41784e8e69fe215f14ee51c6ca89983616dd4846cfed49c6c688c6995047e018a869a12e3db53326d6eed58d872c57c326c7fd14981c80afabf05f2185de379e3bbf44550e1bf788fdc54c2b0123861aa7f1274bae487c6e4b8a2c2cdc8f5a73dbc358d85db35da4de14ef6e3f3e16bc0213aa0e2c3817b6889b6ecdc00895ea5c7ec2b79704f5f361fbdb9605ea0572dabcfa6518de6c764ea42a983e68f32e325ddf4ec78c1707537f4a7d78dfbeee33d996b5c477a79c1a0455fb32bc82b5af3c9603f4f30dc67b151f6f23917761cb626e6193904fa08e886aaee731b137f639c81f1076f15a46bcace244d8c71ada69f8df8e9544871f71a7e258e6c708dd769a636af190c3108f1e2fac9bb9303f50d88f21e22b6e906d53053b51e20e866aefb97adc1a9e477ba7946b9627fa613b88e09b6388afd0910d6051332c6b45ebbfa7fa32e7620f45f3723470568412dc750a047b0396aa73862c2cc1df8f4d20bb7039551caca166f4d778a4a4d6937896646f5e9cc497df5d241d51e1d2b0b8550e26671d8c973a41525410f64978511b6afa09c899cdbd411c75f6b16e54610f025a73901980e9881f26861cce8ec7053d6dab6ce6b6c87048cd604471603e4a7038786e32c7ea19fce8c43101b9b3c077413d0432b39ee0cc1c72235f6f639d15b59db00d1ce7e590d57e7ece7e7b7e198b43f59b1375f8780309464f956e6c91050b49194a55663d06b367016129a807467ec03e3b19599e7759e2326c8ede5cad3e39ea9848b79e8c5d448763e1cef65d5127c0db03ab4a131312f6bbf08386aa2151717ed489c0031b3b9a1012d6eea64a3f5fc493a6c5688653f9b2c49e8068225ec804b04bb9e3c9752e9291637e04268df6daafa2aa783ae0157bd54bfc05f4ed00bf88d897f4483ab2e68845d28450e95080651c00b1a16cb481ed51c1e2a06e41d9cbfd3bbf42c834d8ff288a60ab34d906ec62d52a79741ce2849ef283fbdde5f890d54f849115cf4ee938174938155a96b2801c532856c3584cbead4d2a4ae28c2711d45b55428efaa2c9c70c309a3d879e3b5b93bc04968101da4d8038455ab4b296d03cb69ca88c6640fb2b0a1476bf48baa9142a9ae236e5adbb765a6266fc4c195a39eb7fcc6483d1c418c7e3526acb8ce7c133d483fb5a22b4ba02f88aa7c29fae34ac8c34cf14eaff49130047faac7dae15c9dde4afefff1b8ea53d5bf1a7ee7d0786f93d0b349fa9e556359cf6d4951602e75911fa240cfc175256c2f7d27b3724e07b36d72a24a3d543305b31759a8010cdb9c97821561e8971f82d504012905c39528dd2855548a87377714fb52ce63e71a2278752ce18cf33aaa16696c3c7c12d3bc0480930608b111c753bbab4fcb8768a710f26762f4f2bae4ccfd39b01a5be30e81c9574fafe2126920fa1fb84dbd1a97d89b40c14aaaf3a69ba5678ba0c1f70bfec2f51204bfdcadcd3ed88c66d1852fe7ae59d81f45bf782d69db13d2caec36f547bd5cefae0bef7cd7e4a5fa25bb582205a9359c788697aa42e3f5bc220147daad10df14c358b20ef3df7d92bbd875cae21ff1c97f1be9616d17095561d515911e773b067c79f7cfe5defd117311d38098f88ad71c2b62f126ab44196eb224e3c4d54e126ba1d29e5a95b73cc893eb7408b3851c63b310f7eeb2ba7f298ca9eb1ae99aa45f66f0ae05d75d4894646c29ac358756327c0e7c170275bab7de3394ade341cff80e6952af405d0e746c7d9a4ee8b03d6a887a599886c692dc1036a5667aa74ca1a170016b30f83b24352c7c9364194d5c9e92a9212749e73ca127e323b3d762bc51b8d7fd6ff8f75776cdedcf19a26e706644c63792e5364b8c8476df5fb000dd047441724ffc07b45cc5034c04e76370a43e714f335110dc7822528e5b2ab6b6100e2c79559fbb1d85909c77f67f08dd8e5331e14fa6bd9de262fc4f71385219f18480947e6b5e66c90af5f58940fda3a1aff83a31dbe5592270fce4079d3810000e004e2c163db0e3a10ea947423e4bd6819e5601b3311d2bee1d68958c129941dc5826d7fbeefd8ea289fcdd9e31332b1c58e7f66266392733b2e664a9c91b30ad12d8180844dd422a75e2b0e16d259d667fbfaeb286cbca96930ffecc9a39dcd618d4449a69248a6196b5e338ab77ceea056c64c91e7ab96037ae972f6ed0dc4e158381262b105f2bcf10c81ea8b8c20fb494e31a69b0482c9c9e905657be49198188110c61c7476f1d96caa34ac0e3bb9b2012b94def2ba20c4e44c40612baaf4ce9f3a6b85333be95e4527f73b34f7723981fafb8418551f87e806756787fc780658368739243bfaeb84bba828448afe21064eb64832056a9fe7c95a991729724e6fdf805f1c84b3fa52970ecdc5f4c719974b6a5c633ba8504a8e738312dba1b809c0273f208beab024be5b4d87dee933dc2e52ecb6c0ff162c701d9de4fef2599d49f90155a6e876709d251c9064e30109d543633e3193aeb638a01d89b649959e58f709fc03081b60cdd0626830476696f96eda98cdec411a3fdcb971ac7cbce4088cc5b661b7a739b98dde1364a2f41673df5876a50ca9519db8ca89c53e9a2c51e0a5593718e33c50acfa79d7a83b24dfaa7b1bf24f33e3bbbf801935bc103e021b14fc82c03a1525a7d8ca56d91ff4dd46012cb3a8fa0ba3e8c96887b945f8af9e42f5039dc0e94d0389bae24e353be74764dea2f9c08f0575e4c84138fc4ce0521d66c1f6518aca40f08d45cdb42def2829500467943269601039d11c0ece90ce5e467a14e2621eafb5199726c130fbcbbc47d6a7fb6d04da23446703712adc5948f378d8bffbfab74da641990fec925f9929eed1e28a90237af0761a6f0be5fa68c45b7ba772f9ad2ea1d2a2801442f1d730fbbe201f1b9e890b8bab8699f891e3eefecd51c4c29e24e5368a83d49c79f571dbda7f79c783bb2cb0de1fba60507fe57429b849fb2a0cceabe55aca0fdb07ce615efc4a5acd087d98663ac2ea02b64b04a621c03886b9aefec2a763cab60a9027c005faebc357574dbb85854623d3d3f689bd6c246656205ce68fd07a7f029bc9890bcb16ed7b0105391ef292092c19f1d574f3c2e39a4e9721b72fcb1ae1bc216c16eccdc3be6d75fe3336f7383b652c3d7305b45d1c39e1da29c0c334ba88e72439e3cf1dc97b632aa2211b6071efd3279c9459fe4b0aa846ff5f044fefd56b04b8f7f35ad616169f412efd63955a8182b189f118a3f1c3bb6e3b1df7361aab25968880ad63241e0acb0d5192221f0da98def25ae96923e0280636a49dbfec7f2af4fd474ab0e06624db52ca5b5bf652fb93d6d25beb389fc4f9006414c0b5c5e81b4716a2aa5e26d6a0adfddd94bd3657b4311992b7831c81229f6f067a2bc5786b7436e42b985e308815b71ec229f51f47fbbebaa90ea2774c1e73b21c36fde3a7ac7746021afd26eb0c7481e2896916630bf186bcf4f6abe6b20cd74df59b8c3204506b15a971c2219194efa55b23685310d790ebe76e3c2f981b9405b8eedf3e5b6d46e1e201df350b91fbdf5c487642a96f557c64d46511e8053ad500039e9204db5ac9b452d68ab648ef38a8f1fa65c55a6f4cf1cabfb7a11e68c1b25efda0a086171353f182add1fca03b5a551fdc4ed032da952622b34a250b39112021258ddfd0acf2d3e886696d5f7fafa42d9cc179a495cac9495251663745120b47684dab97fe5ddca1397d7eb7496f385cec17ec11e3978320904d089a3c1d49e650bfcdc9c04967d926b711b798f288ee642800f8d8d0620bce7380d0f2d9be9beee63387a68e66ad146c0263ff219f3b005b52e68dec218bfb1ddd68c61afc9b366551d8344256300293881e02be0945fa01f43db882a403984de8dc30d95212d7fd721d27bfb58af2c8a6a6dd89659d7d5cb90ce298bb9cbf169106b41a862db4511d2a30b11520dd9033b0697e41d417c656a6a9a915923c76b69e1ef8e6e0b8a55cdc0e90ab473ad1fa0f237c0813010406da2dee97eb3a9579ee73aa19cfcee2380f0753fa6f8a51a4210dcae81a35798f56d63641b1e92c39aa6a0342d537c7177e7731858651115469eca3fedf30d76ea057b25eaf202cf699f4f4bdabd8d25ba17c26f1ce9660fd946f085fb9cd292fcaf9381a5388ad7798ae1fb44fbbfeefa9f6a5d93b7fa6dc59e065683d312df26db4de548ddec472c28dca7992aa7b57c059c1f9626fdb14f9b3ca3bbbdcbc14b2a18934f03c68a9de45cb0fc56c0a976f8bde2127fae17f57de460dd8da8e6575989c403292477914bd4348f34016c60037959f2ee621d4e343604de0fca95f44b4441cf544c960ab1e2c456af453963871bff8d5da36ca57be7f2322091af1d7bfd3ad3365fbc1c0de021d3ad9695d923393df978f542894fe8288aa28df4492d2087b87dfe0c8bd38d71b335212d0d6a79c73b980b26997e037967da94ed41e9b1e05d15cf35eede0ace40b4c2456204f9f8495b246ac246562685ea4061c58aba7f21fc8b6358ac13f2e6daca89f61265bdcc30231383bd52d0809540d7629bf69cd7a1b4ef9f5c7bde7c4680ee6a853ad78a81441c65fce9e8ab34f12b54b710c32683e9df7cf1b4863818753a41d2be928c1b71084183b7eab56ff5e92ddfe218372fb223159accca3d41acebc20e64b6cb521c03a547a319d9a47335201ca7d3627b9f8fcc7fb0ec619510f5d39a11952ea83e6c976aed94964e7dde81c373827a546f937f590fcd0b0ba0a8fd96e884dd46f0943de918b19ecd78d115990607186593a64bb8cdf87964a11d21f232bda680a5db6e7977e761f4816a1dbd1e76c6c29318bf6cebe1658800f27630fcc13e1a1bdc9e7b59a386a5ea1850b7834531d483bea5eff047b926f64705ed563eb94026a23bfefaeff7b451e1429d0ae9079d78d9feb8807037d92e238c693eab9e3bbdd991be54ef494bf40f5382ee24c5a99ae375aaee8dea88b708264d68c7dc1c82e4963406660da4033fe0e243138f9df4979dc925b9846a4388b42491f2b906b69a0a9c29151cfac7717453deef5b517f7ffbc54ef6020096a3caac6fc59ddb5a7b6ee47ea217a45079f67bd04fffb638490823c574d016cd3d840879e1f916ccac70791c40d87c9d649c8302048354f2f5f030427a26017bc3d18e828a49fd0acb2f7bc37e42eb1a995f236e541c1ba9b49e690b4dacf26c15370173dd70df3bbce0af26f3a30ab1bcd210d2ddce8ce1f2f60ab208b87e252e15c16cabd24df0f6c602b5164c2f6238f5691d045672e68b813f6df5adabcb17fc8212a7f153dddf9d204f55054867c1232c13eb0fd2d04fb84e0f8c98bbe2f311eb0bf036ca38f9aec97ab2c4a06567c0a7b05e994b5cf65c38e16e714ac9274f26e0ab3f470a508cb7fd5b91175c9f6dbf0aa9ff16600c9259bbd81902ffbb3b857b19677bcd191b8978331eef0cd882f4a3591a83160890fcc5be7a0b142e26dd48687c264b4095a1a6498708025d4d01f5a6716371b7afbec3b5c2440dfe254ba0f0b4a88660f1c40bdd6b973517b2f68647c748e5c10a8a9a5056f8647c1c3bf878ac76bd24d6e1f0e7c627ac15ed6b0fa20585ff9c483c064c2b71d6b2ab237b10bc5f022fd7583bbc9f28256c9b82f1a025380d04becb993465946d5d3073f807924ec59d5e678457fe452b8814c88d651f753a48ab6fba28325997608e7e88429354311b7146c7acf56d07aa803345bcfab28262e7fff5fd1818c46acf598697cac96e329479f2e652539749931750b49d2f3330aba089b264caaf1cde2928a5bfe5adfd16f1fd476bead2acaec0d88e1a73f55f0f5b6f33036b454e36be20690150a3c2a723b38be7f2897a626187c023caaa737c8095025c78ed2fb8dabc63b518cf012bbd52181df86dce93ccb66f418930831e5746199b8fa157b5094c0d4430fb20b91873f7e20f8ad265d90d43ef2e1b473fbf3bd951e3591607b67d792763b7a7dc1eeb6f9ad9bfec3ed634b9901a3b337f98ed3f930bc53441c3b16a179a635710653619f7a26fa4f611f59e2718ba14d32bc45bbcd6c41ab29c123d4ffb7e2c8f60ea5979e301b375b7d90874369b0275a97035250483bdf0d026cd7c412b0a81a1c704b3dd1c4fbf81613cfd27b30adee9efc9ed60301a14b5a4b9a4a1f831b9ea2ae8b9170ca35df5501b23b48be92fcc8b2e4bbf76e05fa61fd2298ec4de1a9265167389c09f7b5eeaade6d23b1a8965c1f8dabc63c87ed0067ddcec11d98bbfd40cf946d70357f486a716da7c52855411336e170ff0b38cd35f24c9a8eca8a3184dc576fb0e4be53220e1c357056eb8f3185fa29a3cc10ea5e4dd3b9483cf8917bd7a770d9b287adbd22907190374de3224a71a4c558bc052282929194048bc86c757c8c0417b746e04c9ef37ec3c5bd238b22c2c93541e159f3e88192cd69da8d4d11229a7b4b71f4cce326ecb7bb4f37232fee49a37cef4e1a4cb23412ba81cef61f29486d39a5ff6630e9ea9e73cbe0f4ae38ed1efede01a14250ec13606279fd5456dbc65f226ba5530ffbe9b9c1a0c49157e0311fecc7033615b2bf046994139563ac25beefae2de0589ddf55479e151b508ee6b9bac3ec21a58a45b51a6e8e26167f8193e4bb6f41ee41c669c2f1ae6299e11c89f0fe501bc30b7d6e4d222100801b694d47dcacc3a4d01f77a4d57e5803d0286eb16c65b05a7195a5398fb5be9994f14cbb36ed7837264c5d9b45708cde7da4f1e33e6912c05d20af419e232337db6089313cad3a711b087ba262b4297edb11cd54a99922d6ca3c210f87e2263db2588f9760828daec5ffa01cb169f2de1b8b3043526180e5e86958f39e864fa020431833242cfdda7b79e1970aaea38633d53e64cda09ab5876f76e313fbfe9c60e7b0a2b2390a99cfe901e3e5ddc971885d53b015c4e5e56dffcaed824732b3fddfe33dcadf65f0437a4d466b326f9e5a5860760078525bc5d5b57faaf22cff7b9bca9897e57014b7a225e71066c8e315f2a5994d1464dd0bc39023ce6304565985fc88cacd882fbce8cf089db11b5f71765309c9658d634ffd664cfaaa80eb557a112a043b43df78f296ea1dbf589fdb2aa8847a9039e147a89eb2fbadb17539e9caafcb0284aed1831067f5ab8905bd8d3eb9217ce36705b628f3951779cc8efddf1beb937e59bb2c77fea59df133ac27b562bc4c9196df9f66faae510b46f647fff374f7cd8af8ac5df5abf3297e37d8aa11da63dead43efc2832f65b8bdb5b1e762a01dcd7a00e1996380b5c77e6a18378ecaa85def447ed7021f02d0a14794992151f254fd4bd12c4906b6dba0165fa3ea2cceef07343f1a656acb65687f326c94f69e895b850e5bbbde4a20625a7facee5b2dafba4f50df3dc28a0c438a0a0cb5c09f436404e44f036fb848e25e59babeae6fb9bea6c07da7533fd07e07c90d2e059eb3292d0e3a7789d3d7b512ea23ec79e8a55ae1a5f527d578e433a73180893b5bda0e2e03e36a0fe7a8a0d02ec411602433f503869b78210aef6f1660c54d2d69592e06ab758fd53b439082a273c0f3ae447f9d0b0c9139512531caafea513d3a3bc548cc927fbd379dd1938dac2ab8c6490c4bdfb95399c31c780b6abc98dc961249eafaa554b856dddc1a7c256eb626beabd2aae20a1da2dac0c3327083e93603064256bacf45341b6fcc0a09e082533ac84fd8b2963d9d46d831d4398df0f253490af7b4afa547dad5ff9c9b52e9d0acad422d0ad43224756162a5f7662ce8560cd04e1cd4045566afe25437629807b3c2dd708ee26fb91e2acc092753bb97d3551594cca78ecb7e03e522e9b7bb15c6bd3a08b2dfca78af82d018efe2924dde4698ef24ea69e0ca180e4d2fe54df47a71df7eaae80dc79c22485974280d226a113bab53fa056faffc3be3acdb9b265f290ac0ba7b72f906f418de79f00b6c0d955e9e5d9910a923dfc353a97bf6f292a0ca95fb50c40882e90b74acdcc023b73aff877a736951a8d6ed1bf6134158ca61a4e5addd30ec037fe4615ca3bc22058303c2ebf196f2835cea0c392e52b4df5ab25973c559ad0124a9a4ad5245557e722b4fefed850f1cb2fea7f24f99135d0fcc3b769f56a0c1dc4020f2db5e0462b13827226573c39be0227efa67336ee275a5b8ddfe0dfcce73fd4e79b3222ce68272b7cad0fcd78ecff138d1d6947047877e1b9f0eb1d52d7dd6caceed027f9bd28d98fed51a9300ce8e32c3a1027710deaead0a3e029158c20023521f847a2ea4a9a9cd0389283b5625c86b098b4d46bb6b5bcfb30987602ff74f3bd946fe0b39490d5a3c26cf00f10081274628669d9a742580fbeede131a50c44f42c0e0fb13451642b19f285a778011ab2f58edc57b08cd7afe0239674835d0d3ad0663522fcbec0969efcc942f865307f7eed30fbd53b2491a42c89e46c4eefc11a36ebea725f5761a9540e05f734b435bc2ac006652fc7fc4597ceb18933bc5c49fb14974731878910a4c0bd3da530ad2773f61b5b8d53a4525362f51f3ba64975a4a0cd5164710157dfb0ffe64f9cd60a3edd5a043e7386c7f79ac0e13e9f5ebf9ab3bb36dae648a485983a986936d49260ff2f40f832efd458b99bb021fd18d5f55a14901c64d71364524ed5469c940eb64cdc9a3cba13ab007890ffaacc908fbe72ffe93cec88f20032360464c4a67d5ecc6efaaaa15c7294d3efaca8d12c3aa3000caefcde39618e92e0b62368bf337f8f460d8418d51bdbd6a30c906fbcff753881e612e9d5b2392e2c61131ad238620779e4a8f65527316c4ad99ea058b3b00293b20a35160b8b4c705706813c6bed972dfca69ef9b6474f98550439a6cce3ebc1758ae97ae21b18d2f8e999fe48606fd106e540ec8deb08f80252003dfdfe5c0f5108f32463e5e345780cf4ad0d8c75b18e829033e857d3459d49f5034953b958007da1c7779c12a0c49f9fd617ff13501613e45acd0798fad72a0872220f7c63f4ed250f2ff4a970854dd74676819bb5731e1ccde534a610bdc432870d5d8a0e830b1f809414e0e84ba63a40cb6130dea99d02c0a683d5e6ec395f95758c7bc78879b15a374f97757a19248a99880885c0dbfc757368c50e52faaef1509b8b1fca42e6a884ef4c957bbddee4cfd1c7ff786284bf03a1c301de8101ee306a943edf5dbbb2f71ba7aa328ec03e8d2217f090144f1302b4ecfa4f398daa4aeebe6cfccc485b6dc5069d2a11379b0ccc472a7062e1334b5ed7675e8e7adc2ffdf1585a8356fdda28fd2e90bb3fc0b6101ce472ed9056f2212cb7bf6777c6128c2b9bce2a7c2db84a6b54ee169115fd821bcc15e80f9d71c555e9f416c8eaca509af4786f60023dbb8f3e40d8e804f87d661d7d07855b2038709e64c2f53835b661759c5767d8ff225af22c3f7498ecf94ce6d3fa0ee5149aaba7d71bc4435782e47db5ba8b73cc21358b5e8a5bba92fec7019540587b13ef75f520a1adeb683e9d23f08c636da6bb9c6e7445725a6974750f04678c1a7026734860287158ed67a12cc573a8ab2826a1ae8bfb988e965ae5861de553b2efbcb18fc31497fe7aeea6bd6f4889e4db1ceb918b51895ad586dd8a2e42b3a23c59f8982f7af9f0c0d15fd59f939464f3dad31b42ea8713487927036023e7ea51ba18e543e1c45b8fdad95c7b4fc6e3e1cf9d2a5fdeab8d045361aadd4eea9269d45e1b2326698ca71a7b792949bddb15251e7d0d4e22072b382f57dacd40cee903b1aed3239ff3d7270d941be2510c72fd3c61472bf9045e57766977aecc2dd0e7bb95759b4e43aaf56de51543acd011ec28ba6f85aac05ec23520efbff58bc82d2b09dfcb8d17139074d6b7afe760ffa850845b8dc29f489bc3bd08465ac0410b994cd8f3db370cadfeb549da74cffd34e6f4be663fe73f9c79851b0dc20890e98f91a48ffddfd68bcb5ec3144c836332c026b91c036b4964f677c40e82466cca90c188a10b0ac45743ecb19406b583ca9bbf0d86d68c7b915e33b5bb1af3ed248abac587577ac968dee41edaa783f3b3a4409a5ce6c1d774b392151d896d4f0ead1350f3afc61817c19abd780a528109cff8b3349cd22aba7ca65477eeea3d7f0eadbf60462c2de73ee87c644403332740a5e8791b6e36178a9ae37c324f68edc21c85c0e2bd70030e5960ae9d1ab620837fa2e88bb9e69d9f7dc9ceb99a81e5f664ed7c3f5350a138df1d8872aa6e43ae66c53035cb4880b4f77585b5c046f7e5b19606a88a08b29b98a1a1f436c8bfb38de7f1644d8d207aa0b7d645bc8b08211e0d582664d141cdc82581c217bdc0596336e43873b800d65a0b8c4eab8633e4d4b9670ad695c58e6e58f34dad5bf43a1858c392cc63b81099bfac0b9d3b37fb7c7b5c1a2596751f1b5e6bb8a2b386b6313db82253fd466d9b915edf3ebc90e8fd07c41c746ab0504491067b59514028085743dc554b56b1b70301a2eda2116f1da300103399de25302be4c03240f52bef5ee5f850913f6e8cc728383b8cb7e6482264b0601383e574e4164f201dcefbcb1a64e78c3c9638812954d669e2cb7c54989b327fdb47606ba83960c22cf2358dbe863971b2554f18271ccc52afe5f0d481386925486a67af55f16bad43b2fb80787ee4315e364cff2249657a9a9e107b4a0e35c7d9ba4a34851384521cbc2e34d05dd78a4bed3758d64972cab334fad85b50489cf5f480373bc4b0ad8159bcfffb8bdb4a1c82d5e31112cdba74610c783199b1376303672306cb22c7e18311ac62f5de6811fd95e7f4f0821dcab5aad1bf641f5ce9ef2afab92333dd2e23f9ba99bc4459416333ba1e8e66307bf7fc1496131883b73cd9213b1aa2bf77e3faa80f39cd7ecacf22feed69411be89fa99b959541130b0b66a1b91b423128633f41aae86c74f9f7d76a0c9222999105ba6be667d17764552b8bd06f7ec3963ba7bcc8116d092bc6d1825c2057896b58448d9b82c635e43df9c5dea3c719ad5a469d68a8d080d410d4554042fc8bb976b8e28fdfbdc2defc1ab188e7803fadd428a5d3c3e8953eb9440a3440be91f2d91712a04240251517810003f428d649f6fbc004d7f5c70d7eb9f0384e6a2ba9016acd5963585f2f406ff7382090343aaa99f83d954d44e6d4f9ce30b6f1dbbdde4dea4961216bb23d2db54cbad7305ce65a024e81dde46330a280db5064b86f803e4abe809fd70cb24f8d923e99e87af3a093f3591b9898eca8dd2b8779fc5d280abac0f62509380b780ad542f6b6d392a6b48ecc7c22cf3c1b510e88b39b9000246a79447af9d046f9e1ee47fbc647bfd96da3e79cdc2b6c914483eeb64760df63662241aaa2a65e5b9a7202233bbce825746e12d24c08ef64c471ed2ae95cb49f9802cc3ee39367b32f6f934da1ace2dbd25663d786d466fee16b9cac979d7a2598a2706fe688967f8a518dfec1f506dd5b8eaa14b16537b8ab077158efe1a0adddad8dcdd5b9d76149a3a4addfa3a350f6215a83c116904cab4fb3ce053717c17b2377733369e5926da89c0ad6cd56436c80e8fcbc7b328858cffac467b7a165ab1dff1171f615bf2513bf644eb4e9cfbff44e4f52bc686d6995af528c8b044b74eaf3e7ab8dd99754afcd4dea9eb8cb3112faef61df64bd74536c616717b429b79ccf67a05af6956de1b7ddd59e3b64d0850df6a00782140a8817b3268d78b717b79d795cfd8e5e6425df30602de48880700f05ecee00a46f68117e4b063af2970966949002d5bb8eacbfbe7bb55978fd558af18a6e8df901db4c264772117f9986e4b542215fe7b603fc6e6f6857fec05265f3f2628d7ae17c48aaece8fa46b456a8b12d17e5a1681655e3a58815a538a2840686e62b26a595039f94bb3b14dca9375091da8cad367f61edb0ce429a61061addf9299bb9bac36862096ce06fa8627f0f97da4bf4524a66db5f50cf8008ddf736af85ad3936ae07c870ebaac29819df76a4e07307864cc14ea90ca44eb7ab592b27e9004b0e58439a4d18f5d571fc0ac782a8a66be0e967626dc4e0dad15d48e284c0747d8dd337a1e9a6ad56e48655b9976c67f5840b006a01f6003f7103aa789eedee326724d4ae8ea307d5a5eaa4f6969b5dfa3206dec6a999d8f842ba074fbf325119c158a940c73836adabe551907568ff9b18a4073bc549fa4c14bdc0cc0ce5303f8a2ac36e502897d38ae30e57e96756c94e0ad17de80461d4e1f719d986e39c055882e38d8433cc386e21fe459168fdced679984ae14825bac7795f437cebd6945bf6d5e6873ab57d7d4a9dd2fec6e02afc419f106d156df065fb405b17756bc872a30bc478a3db062b9ddabd94e35789db34e3cc590661132d9dd0285901d0340587518cc72519acc024f5493e9b24f7f3092a3479ed7d5e34f07affff6afb3712e2f842247a5a4507184fda4975f53aeec44ae8e74ddada7e72220db8fbc6c5bf5a505139c79112f5e147fdbf2e30d1060caad55e9558b8465b5a34c945898cce70b9e073300be97e84bc833c4fcc38d3487fcd739888800385076fed7f859ab328d1aacce1ff29de2c2cba7c1716257a28462a555476f7faa47d94fb8c96661e7f445dba5d9224ac694eefba03aefe6d3eb40a48a05eb623c1d2dea549c80279d0ae746252df9713e69ab669b07cd3bee741fb9934b3172461ead3787eab8a967e309de1068e2b3235049bf1a4cb021f815d6e2ae12a3db9a187e474acfddbd48abc641f7da4a7b5f4c71d3d344d6950db9f28de389a48324f70a6a3983af932876e30a0f8b5d566a28d3d2ef8f05418a06fe9b4a2f52917a109cd61479a7ead25d7b04acb52e14d56f5acac7ee854976304ee90286cfd34d20d1e1f04117daaa713a16073f92ef31dab808c4c1d2c2191b057d0088883a9118a80fd21b28b5af30f019e7cd368e997e851cec3ce020b4a835b81b5a6e8d34338e67bb0e9fe753eebc8078f6490d897c873578f6ba3c2cd53f72d74f134cf30fa4efa03755f9ea3b08971f8391303b14b8a12c91e9064eeecf3462e1597c6b7c60254d5e4727e1745d786c1833c750b26e5ff6d95a07eecf0aec869b9aa5a36c34bb5d05d27ee1d1be6795b87c155c7cff183d1bbcca6f1c9a8bd30242491bc5fb75b13631de4ea0f9f8e41211d2ee7313d78326c364eeb01b28a2f3488c655a23d5f2f1f6c1f8533670ec3c539c9c818a08f14ab067c0308532b9f4345dd9f20c73b3e56daa8eb9b7d39e331475f907d3e7be2bd4e6e0482be86494c32d4176dda4115919298010a0044a841055de9966905d17e9b765baebe465e70c0514023bbdc11a8597d9995c1a7a18e42f4a8a18564056c4580e7ab70753eb8e01b327f0aa52f9f3c942bb9b67639e0b2eb3b0055015eb5bd9246fac6abd37cc272f692acd2644a09fe41d4ad4f12a02c43fdbcc9835ba3772f91ea4106652a8e9f027c1a948db3c010c2385d1b43837c0e9a828e536a6f43100eea3170e86cf572900a04e32db2fba51019465b497e55b9374e6637944a474f37b82f9dc845e6a36586dde7a648eef94e3795ab3f3046882d2b34f7c8ab4b8b6b9a2e534d31ac9166ac43c9b0c0338e3c07bd24379c421352e54e1a2fc3f18c793d6563e8563ca884f351c36e852246c96dfda3d71366cc911ffb547cecba7b7726f23d53e003e8cbc8b0d7a355c56ab84d6349d89bf1b5e2e6a3617785ee040d1f3ea671df952eb15af4bd2f83841724a021d6b9218b1728e06e78682844813227df6a4cae5c24138ec5de0a67932e1ade574d425145d43bbaa9280af2f110581c86e5bc6c9bfcb562911f2d3a389761f9adfe588fecee9ea9ffa89482b3cdb2c5f980bd25acb2b783fba16f5e2bbcb8d785244ec7b2415de28371124a970ef0e051095a5690b74dc0e4c09479645088aa479f7c25bbdd0faa0ec1e05d4f12465df0d23bf317170911bb5a5340f94b3c349525e6a76022750c25433436a350fc2859ce4f1f8cfbf2b9286da2084d3bbcddf29524e97c0ef24f43744869c147c2abc07406aaae7ef45fb9543a68f4ecbe9289f53b66612ee4b911b5686f4bad729699a591864077b59428e33f75d5f4b89d717ad5ddc73eadbcaa8b097b6251921de29be1dae968f72cf8740e370bc33631049b25d24831f3bceae9e8713f4f409f017576d8f3a941df9d3390ec5ab33e501b9949bac371b1c856b6241fb650078b0eec3aec873328f071935817cac9d474e8f74927113faddb97d3d1457711f0d3ee4e2b79e800c4b70271fae029c12ad38559994d2920e9139826694040cc40925b34f4bbe9c5d97686aec9310c87530d4ad9ae3bf0c1fbaecb4f37ac06386e4bd2541b0131caefe8d59908a305ec701847583ad8719964777a3b605717008b5ca899153731e2c3d60313823161984f20fe03358e49edeaae5302b6bea6f2f097d54b139d38a6c3a82c29d8f8e6dce1437141b1bbded9b75e7d81bb4be92539120e5ee5611dc822e0135e86e84e3381209432114b52b05108b7ab6dc712e49a5576f3522e38713bf738f843ae32861ac3742ee6aea3c358956a98e12e21cf0a2987b3f891b0de85380a88aec6d0c8bd77727f77c54ffb78ba10a507a0959abcd822733d6e9c21c97a4b7e6f99e6b8c5ee25bc5870e0a9665b324e1010f32661155d8a133306bdccf6d7480255cd3463c94e7661e34a662c3cf52eec24762c98e29fcd8d69cff25c83b65ed313479741543dfb1afb7d5b5d10a2471b2dff1b29499a0cbf280c5a46f1c22ebd1ea9735409e9cbcd6440a03e9cd4110edae2b4b7945b6ee36d918610edc44d0ce9f5202e382cf04ba3c50df1dcf937d8633a31e0a3375f7daa8f17c6a3ab16b89e75225fa041d7567229de2ec98fd65ec8b40c796e1bea054476bda627c3b66b00899302cfef34dcf6e8deb3fc0197be3ce429dd205204dcd62d2b3e4de54817c2c9a0bca5250ef5c6bfc0d98c65c12367b9557f3d0817a795264b1752434cb65babf2196b8e894b41bec1d5970753a8234d4689920f1ab8ff4c71de00c9e05a48ce160f72b4ceb114170abeaf54d508e890ca3c5e745142d0c681ce3649c2f877c08bfbe33081dd5f70f12448132cf8912b6dc91f958760b744e6beb3d963aab8fd8faa23f5e33dcc4c1c31d6dd086440c18b1429c0cb259555bbb6bcbf8e76936e69770ee72a118265048ce45a7fc80453c5f3351c9c249af837105c78b156594493475fdb5cb3e1a428e9ddbe27bac0d1b0d123f8c9f4b764c4eb0eddee487cda025ee7a2305764c4b4494d30902906c438379ef71c85a56236cab48921183f85aff3a24b222333d62701fd19efce339873e2af97bc446e4681a45a7a97ebb5f058730b4439f9c58be98f8a9b91ad489bf160963481af6c853eac071ce4dc5a3ca29bfeced4c7c6073e7f4174104d41d843ff52ca98678f395c34c4890e2c93d33650c11f582c5e6b7861a358d783072c48afb9450d2318378d691dcb15aaeac257e66f75e5430111ff93f4d4a6c1ddfd99e2988d5512ecb32e6aa5a4d5c6a3e47414f21a98bd21e6b5bad9f9292b61b015ee29934cbf0ba34e75a9520581d5567f81a1cd488e93eb6559f55a36f95898c6a16ceea97e455ef94d1f0a2539efff2e54e0bbe945d61097e31ba09183140c7dc969bde5da1357058417fd25b537df77fd5a40ba62f318e88837bfdbcfea932613db77fd881ba3860d8017bdc16a10ef04344b8b0552447ca6933a169eacc57df52a4fa8bfbb25cf186e193ad2c953d930aa171252e8f544d62e8230160b929412d7e579dbc4ebfa02466203f857d09cb22adca9c9b0987dd80890c71846b55bb676d263a0fe8a55b8ccfae2417cd16919b42e6c14157397df45f10176c55449771d5cc807128764b59fd7ba18b93f0bbe9241a73a922555687a8d15ac0ea57e6805ce9248e8f3bd42acce1a7f9708e8ee1176aa9f6ebc6f710283069c63869102204778a24f7b22e24a3913a335c8e01f9d97a1aa44cc80cc16b7981a96a0f7b00fc4d9e0dbd43f44a8453ba637989421b5730f9853c4511d03a57b1dd0f5d3f5fa58908cd04dfe652404bdfc4005e6398d2004a41d31374a7536d0c77d74bc5409060ad68b743d8cb053360a2fa90217db46027ee3650d2642845b1fa7fc6ebbe15acdf3ec9724709ab689507adc9b4640fc8eace8af9def676b19b1b1baaf5faaa2670370c1e0c21718d998637aac174f419d38f94ca08c7414a796d76450c48ed8d2dfd62fede36622c9e0966d920c7ad76b6f86b07b966f6eeaa6c731ae38bae022dd0a5f97726f0cace29b4bcd7aaa7850b0a79f06fd1450889e2588f8937c6e05891098eca614ce3b6448934cedc33a6324acf49b801c34717a1506c52b6f34b7baaf0d97d6eebbd6eb0e7e68c342deb7af106dc0a3b04d7ca61c625f5daa1154f575964355a0bed24e82199dd4c4e75943e15f8adb5fa479db918eea12b5769132949c06f84b21b09aaa74d78bdf958fb4379bdc837343715791bf13541bb772a0ab8f8464e759d50127c7141d6a1d81cc663af408aa5b8f912c25a30e25020408ae7becc0a32bf927034f2506adae4bc5f248007ac778d8de9fcc5dd021dbd6c4e80e3ad50d402b0ea3f8586e892b5aee856d22f138d5544c9c01b26a8f29c698401e8d6fa58490b7811ff933ac5c52cef8a9034b464281eca1368a42df3ca1f126412d9bc9352b7d013cb0acb5d909febf8df511aeccdc5d7d666392c4ee1106f912a040c6c6ef3fdc0662808ebbe234ccbd0d45c22918373c85259297009a2ada63786bf8375cba73fa89f28ba437037aaadf6293af83b91cf7d418fdab9ac48e8b10b0410a68bb4403fb56ebfa0e8a30ff06b2338631551c7058c3aeee04357eb5c7c1e9f5d6bf157fe1016702634913784b0a73843e4159d5f8c350853e8232f66d22941fc495d66a875600065e178d89cea3929b4399f0962b53d1d5c756306c54625ea1bd37cb1c4f5cca7cd7d388a6d9dba59c9240fa7bb41f15548c5d6b3e7279ab9edf563f81e2ec80c5aceab91082f04b78f02bec89cc377f5f64a3b101c926cbdf7e7301a4eacbdd894ac5aca1f191b6287dfacbdb2999cf01afb2470555f1e4ea88d74a7a98dfa2124924e8177638cdea51a32f158f9488702c64e4375b756f3f24ac53c119da1b9c78c7bf249f4b7f4d54fa1caae37f68e40b8b8acc6d72b361a03eb6f9279f9fad736e0ef93cd0e17beedbb2174cfc0f5c5b9e69c124040cc219de18ee29c18a852d3f7dd37cb183acdd28075c1264e03469a29d1d23e074f8261d6883cb2536d61a6fa8f066a6d554df364ba203844a1d14d312786644f9fd33a908e0b727ea3567c9f1f99b66b3911567f582d5b304dd21c10296faf0580f89726fc4b06ce597977301177c82eb2e12a2bbbddf93d13d18b04b30630c262949037b0e4a1a862433eb617b3f62212729212b097bfd695e2d0b793c7a77c1dd28b7ce1c03ed764cb59a4ad03b2ab1e235858d06b7cccfa03d544b90dec68676f011fa71132e734d897a1439a0857fbac74fb0d8baab02380c2e3a0411963380c0efa784b43cd0a009006f36e3bd12c32f2f215ed17c9273c9746c9a517c6ffb476a5fec3fb83c0531e827b7050734ae48be2c2ae72c377c9813518d83c462ac22799ce818ce30bdc3e5d0e2eff7fd53368e9ad5594873e106850e344de2eba304109f51a58fd4bbc3fc0c688d29a476310aa4da25c1e9bd8f5e15626cf1a689d3d0696246185fe501fa73ec598cac24ae634a6ef8c3abd17b6442a61433274fcd59b86375069cf7ad73c16d0f550b6bc94fff485c3c62101c86b078c026b7c8c02d827b44f0a516ce3ddc86a195b6a62af969c209cd4f6a37896a405ca105a416c641500854ecb031f502b7d36f8ffa79bae9bd68090d3bbaa7ddfd0606ce664f59eddb2a66faa87ba4ba4a3baf08c6b4c4e88d5a6c7a28414593b65d40764072a5638fea26b0e31d209e8801621b95a7894108843235678f670eb5ee1b82f7fda4bb43d02b31ab772122c3eebe9cbe623514a254808750caad7882f306ba329f40054f60d07523be95980993e3ea851e7684926399c41fbf8f72e02e3709f2c2270a44d9fedeabf87d527251ee93a2d9185125309ed48ad13034fa4dccbf8f7c617f6910a4dce0bd123d6c8f346075560299e32d56115d193e8cded2410ae4c427249fbffb031bf2bf2e93268eafcaedb3867494034a5f5b06ce45d5ba906a46f678c775cc0051a821002588ab199ec163899c2479bb3b78a849922ba62c57821a1d8543e791875b9075cfbc3128f32d8286d8fb9763baa8a9c58b6bd3930318c83d1818896fe677763e36438ff6e7cc0b44b4d357f5c905a687d219cf527610502ca347d9b62327d77dd81f7ff0011cc037d0a28f004c1a0441bc34df7705c67aaf30d7cead32bb429a797adcc8ae4fd5448c08a481d727cec9e4d8c03d9bf883028c6c3d7aaef06403dc42bb4813407d3493fa70109878229b9cf542e64e3e7e1b7c8a5a67aa45f5f491907137c706b469b9b5225180f232b73870181b7c8f1eb4851c2f50d3cba4fc65f8e10a9ec667e56dc95796717a40e87af551d43fbfb8ce42dd8bd82c5f6f69f920313d2ea9f21d1fca9c82c3d89af9c543ee88ccd6e59707c44ed1b7eeefbefa3683117a749c846d008892e7baa4f610f7f76e4e6c95a279daf42dd61efa5f02365b1de87c8ab17156cbbf42f51aa5c91af532102a6462e779e694a1cbca3f8af05546e34a275e2319950b48dca607a4a2fbc7f9ab8f1fa5142babbd7a368c7501c782b6f2b227ab3426d2346fcd58bd73a7295254fa4ef9e1889cb98a053aad205f3f2249de6fb12b008c221c5cc2adc94fa54728d3064f47c65b6454fe4a677ca50971d2ef282a9218eb485738db397ec394358159a4a805007fb5b3eaac9234bff6b4dd544f039d311932737a671b1334beb45e99a7e980405cacd233a6954c345936d0971a596a3d3fdc2c41ab61e976f2e75b4e797d71dee151cd959562d6a4ce56ff171eff68ad554b8dcdc8ba78fb6e07c0e3c0a11245b6c7ccedc5390f2742dd7bfef1afa866a377d434a993483e15c86047a35a853a4d26c3d971aeecf7653f319abb5779b8f02cf2cdf9285942106c415f41016e3505b252cf7351ebdda5adf5c6d70e92b38702e85688da7cb88cb3d2e6282ec6d9ec467194a11d90f2a27a2151aec41ab38cfa04d3b93ceed499aa63305d739656ff1c7b3db65fcb2ee7ff6fc611dc5b3c90609db6da9794a9b4928fbf470a5eb7fdbb469e73ea5a32c7c60fe9c8d7622c009c9f858846396594798342ba766f637aa4340233d577c46fb8d5304be9660781790808d204ed188768d8e7e645f52550151f63dcc6c6cf67db733078ab22b6c1d42bd5c359669ac608fc249a86874ae3bd02b1e65172864880e7652594f8201977098e0bf4c2104eb58a8950e5ea281646a1bace2978752a4116c69b0a24e944b69a88353ff4c67dd0a1b74dfec84e549a96505afc361e918a1f1950546943a0761bf53ca9585a31505a9ed5dbe240b13f478480bc9495e5c055175b2107fc7a626904d7e277d9d6d8b1bdc37df661b4c75285dbaf8a98867e3aee25689dbbc27160ff36597ec30bbe1f02563adfa07cc149c23ed22ea23d02ce6fb7eb93e0be67159cf929dd79dac1e29a751b710588ed72ed2964c3542eed4ffd82b5ca676800de33752c0a8285c88cf9b27b8e188104de4f476364a0e2d77644fb57cdc55d9cc90c6d0acdb5882ba5904b7709a28059b63b5c86bcfbe1b9de33eb7796d1da0f843a20e98967125cf84698915d61e872ec221353dea951fb6acec192748c601f93de407a94c2b583ed9b734271e4a107b8dc10513ed4f637dcb9e695c5f804a34c2e535acc8ec302ced38148909de92a2046c96dd1342b07eb7fa4d8f0fe34e0efb9a214cef74508c800fa7670db218eb71180258a35f54fff25a5a274df2fe646854880874b30b6668f139b79f6b8b3141f804ee95dafe0b71bdceaab8ea13bb1a96a04140c7fdeb57313ccc336d7aeb5b0ce65c59f19af4d60cd6a65b4c2c6b011ef2b17b58c2a01eac5130f861fc1653854f6b502e6693dd96f212f61dadee8d36a90b90d1f04a9b11e76967a19288e3eaa0ee2394d96abd72afae6e007c23111b8b8b6c8aa199205accb5177e33a122fd5ef0cba5ec9f69b14d2a84e9b2af1d3ea9337439b665a0a44561601115d738fbfe9978a3ec84c35391ad843977d159c4c23e2dcb6f6521cd3639dabb2507828c2ca86f92deaceb54b019bad44b4eb3bb5dda8abcead5f9c85f596bdd3e421523e22aecd167b22f81fe8fb1b9ea9b2d41eeaeb29d451e6ceb0bd8965848879172c16dbe410db91cd839cc7aaa4310d3befc41ba8b05b79aafdf6c9c00d4881da5a886460dc956aeeb9cf69b2909b894b7fb798f7b62530a19395e06af1dfba644a171413a98e1fd1a2e73850211881594684f4e094b398e7700a6ebe8c3247e0deb82920cb120d6c4d26fe2a6c58f66ad2bc9d0d249173902997360b5f42c467a2b810331d66c17664204d70529f110a19df364ce04da7041697e54dc6ca24c19312e78c62d2b96d7243be0a4b43915f913f6899ca458b590c3cf6724541d425ac44ff092bbb82b9dbb4e489f505955758a5134ec2e49eac4853ac3f74c4717fe6ac3a86bf5c366745b4766fdfb7f020ccf6157fc1d427e7f6f33ac329f1d6c8f59ebd9959ec29e2269ccf22f8e2c89751af18f2016356bdd90f7b16ff60a2556aad2fd5dc53cbb46de478196956744f8b001de6dac6b2ebacfb81ff9aa1016284cb10dfb879f884ba1f910699fc9c725a8d7091bc7fab1ec050a3bae7661d144f0eeb5400930b00546f68116b84dcdbe94479c2fdd59c3149ef6981df8fdaf55d180416b44ae7c50d565e7ae9195ae6bce2221573e3f04b4b1d56efbb453ebfbc43260c6dc8b3749b9dd8666d470d1184bb63c65dfeb914bdfdc269e5dfc4e379900f742c0cde8d831a8804e5f74804196817264cdda6f1bfc2bd765b7b938c3b7429dedd3a76c31de2043b46672936aac18041203be5d4f5b742435f2c916a221fec243a3313ebb07db6bb394a890c38811cc5f2279216f7e3d417f5dcac341e737742e69c4339cf589673a24ade06351a4113fd55466d4932cd4855cad97c1cee23133026e0a579fff04f5a4be71ffda2328eb4228c3c5764eddf9b01ab57045d927488b004404cc33fc461d6f890a900ac40cffe3ee221e657b35a8fda97cb2e6f976a57aae936955bfbd246783aac01e959fbf9a5a63eb527792f9fa0348b825fb669d442b62dc3e6a1ebc31a29ff32dbcf3085956467a27bdef14fdef6e1ec899273887be84766d8d0c34b2b33630cf890ddcf8e7db3b779089e34bc08a3695923d35bf797381dca8025c3b5f8b94f7ada9deaa344a1358edd1989437edebd55fa208cf0552f13517647dc8c471f2919801abf7478d32fe8867b1aa840c4f3afbebf645ddf53a272fb91f49ae470db9da55d838597da9c46f3ddb1d79f1b49772253e359ceaf10d7d5bbdd48cd91108991b16b4b3127aeab599e7c6499961dc03473765cdfab7d2d8c9e59aa89d8baf94359fec32d19d7f79038af94c81efbbe877e81e9a14b0072ed146a52528368b054dd73df5845fbddcece7007a0e2a1f7ab46356135a7aa42f4688e20237c3821cf9011f05db70c2e07de9e466a5467dba3bccd970a383bff40926c9b331a57126074e7f65d3379820e3bf94eb10f7fa6a653fce40b683a19cb665b1a660b9d4b340e8891c1f2e8f41fab2ef3a82305eaf4550c16fdaa1c3a50f976dba441376753ae0b821b3afa28857a9bb524b42361bb17392726f285ad1f720af62d4d6bb4c4aa9a9e07bd31007ff90d69a0700e9f82a1181fd288b97632a6ff6d41679ec08bc1c68a4c93daacd7d5fcf7a69f6317e3cbed85eecfb3505e2f56a9fd7a98a8e938750f934377870e417f619ca0dd0999e54067253834f2ada971511b902543cf708648b80796f0b4c85bed6aab7831c2646e8f33b9f46599e746849d59ec89b8f85ccd25aaa2b7e1ce004a588010ac7a537808a95795faae1814a19ab302cbea2200d05c57d5d14e55cd41b1bc1966757ffbb5c2739a2ca56050ef0ebba7f6a7ae3ca29293d5b37ed1bb927cbf3e7f507a7494ca4e24618293bf3d94b93da62629d7d013f4697e2c06f99d3d8700d0155df7ec49483102da4465ddd2af6d62faeb2171b788747c47c07667ed4c8b6844e8b03959c8ce64692646d1be1a0314cc2b440524abb348c9e597008c5abc23609d3c789993d972404d48106895098792c3e190467998c38e2978f84c7388f136536fa417728e77e615335244020d903cfc343e39ff70f53c15fe86949f77ccf94f77f40d13fc78ffbd92634caf580602aec1cb6a06057d4019d325711c0c35a1841c28e90b5daa05ed2f68d4d834a073b4c2629d06caa2b0b41840c018342036f447c0808a6100f3e4ec5a081ebbd5eae286c49ad626d02903b60d4f2ca068eeac894ba2bd346dd277dcf9bdd3a861eb5d7848933e3f0518ae0d1b3bbf37826b79149d8f810e8add72bdd4d7e955245dce3070ca6f51461a722dacf26de78f9406b7bea3e7ae324499eee1066d6258d1a44aed51eaa51fc28bf06a3c64032a4975b1efb8dc57668e491b5a1473dd167b7ffd1391aaec2ad586462b9331ed869bb58e1c67a420b3841e90c179fe845f1f7cac175dcb93785dc8a45673544150b0dba3bb76b9aea8d05dd70819be791721e5a1c86aaf109f1465aab88ab6da42de50d77549b2176b40b304dc5926f1153a2c3158587659094007b12a740bfe97ed140605c3fd4feba9baaae106fe92226f9542d20cdbf03d5da7b82f5c930b3f668bf027f2096663fdbc6d0c98b17fef19f5338a03e859a2f8ae564ecab7ee9c388dce0794044fcf6bc16d8eac0aeb7d95bb1c43f90ce2b13f719b547344f7332f4a9662f04675f7d8b7b2358ce1988175b640b31d6b2a85c13e67de08b2b90dbced49a4924a8bcc9f98298c39a8ac4d12d14af8b8e02025c333163062b87615019099c57e9ee37bd49756140d805f6a3986c1ced593ae1fb6e925cce1b6c502ffb4cd2c2243eae59fe85b0e402313f7fafca7d29e06b2cc5a5c0378df0ac1a13d7f79976726d16db1b8837b215e8be4938297e32c517ebd5ee068cb5d6ae8952bf5925494412fa50811662ff111d4908bf0816df8989dc8d7d70c92c07428654a2de1c6ef597ff376bfd8e2902fbde6445e0a64949e2d50a71317196685b26ec685c92538b6f5aaf2e79c70937a18a3c45611a822bdcd74a09f4cec1c362ad9e0afd411cfade27bb1ee5cd2836cde4b20e067fe165a09ab8bed870a00fc1b9dccdf9d5b3f7508b8180efc70b08be873c16cd29e180c982d47763557dcb03f515df962ff640a0961fe4ae9f3d5acb82ee2628e4d4e4f3ed8f91ccaa7a8c037f3ac268469603257aa82781d1e121b63e092f7b8eb4b066c39d44ebcafcd682144bc618f42fe2f48f9adeddac286ef89fa1550276b2fef084ca82421d37c16de5726690990204499d0fefcf948be8babcd61babd54145a895bc091437b0ff52b1be963a22c714ee3a100e0ebd0ae30278c13b8c4a3f008fe12569f46696fe26708faf031af297336776d54b716e7b8bb586905fb8b7860034fde78ed84a62dfa41a0d5f4b3ba2c4a7446568315c12b5c961ba5f299c87d2d279391a2ae3ac960781b4ff1bd1b32fb5cdb85a57b6ce9531af2e794f16444bbbba716072f776d28929ec1c8e085c15225bc0d53e69633c3fe6aebb31d4f63650d5f0c43538b8da4380680b6185529eaf808a9b02bbfb81ea0bfdc1256cc0cd194aa92860ac3a926220ddbd61ba08b13d8969d45116c6b35703d968372aee490e0a231e0ec68b0d99215ad116949841b3130f684b26670938a7acd8359bae057d4b0375fa111f53e9076c11f0eee3e6341034028cecaa232f362ee35650c03613ba8c2ddda2cf5b20dede38da64f062f3ca88becdb91206ff6339801f6bfe906d79f8d7e1ae003393fdcc685c3e9e99fb7f22b6ed08dba9eb7106c497c3a16954f4dea668fdc8f977a81e26f197fb9bb158a4a776af6d057994fe7cc65e4744f6c9cff65d79c0b70878f72f1d061b68ad77378cddd32bb0033f58ebdefef836aa440df872792591166c07c15ecd18c62b40aeb037e4244c360c5dde3559c67f41ed0f4d31c9ff6770548c76b4894e233490a1a51ae4478e99a940ae80dcadcb050b9c64efbc6cda16ebe3e81f05a0feed2595cb4e7473f43bba501b2a79bb719311516b22a6beb49a079e1fc589f97f74d586cff367cb90c10f67ca22c2a029369e1b9e7b8f629ac7a7472c7c15b689ca188c5e1769fc66db0a0310c2703336df9ad327d2b72c77ef3db9ef79dbe9faa9aab0381f6c0390eaf7b08f51e52715ef30ad7f99d94b660c0a84b0261d2905417da5b194ba21832b2281f43d5d10b1e258d0105bb675588cc051d470602057deacf898a527d79de12b3069e170fe68313f3e28e3a931a01318bc4dbc6246b6a621c4a89a4e5a8df5f8bc69fc9eeed08d33906e9c7c49c6d655ff14feaa2fe97552bf86b349d0db5c2c0694539f38d24d8543b66b9adde7a4f1b54f48eaa42667ad8329a07a30e01c0210f6cadfa148e96214dbccd203a06b79edd38f6ee42ff18f9862347023ee0bb20e656d0a080d3925e41ce06398c4665cfa2e40176e50e9c805a2bc12f26cc91382430f61c73d9912bbac5a875ea2bd045a81a45fcf03c812eeea2e77494243c006861b4b4ab1ba6697d67dc4abff5594a56a49244adde1bac65f646c9ce4ecb0428d82ab80456ae57a26fe42ccaa5ab05393992dcbf684148ddfb72e5fde9d298c72570dc4213f92f1095f9c7cafdd88a6fa3acb9f916c924922a2958cabc096d79e5e58def8e24cf21b46bb4d21ffd87c7b19f98c5e07e63f592998a458c124588751f8451a6cdad409916f91f0c06f186649080d79c793647fa1b64281629b533604cd85c46372ff181284d2f7dd47750e34ae5e6937fc03794cde6855c216c761cbce3113a0d261f9642effe5cb43214b89c0503b4a5e6b686ba50784f67a82f851b22a422917ecae0716266bd5ba28ef3e4589fa149bfc09e2215adbaeebfcdc320285419ddb96926e2ee99127cee10942a9b86c7df85fd8c25f6362e5a54504c6fa9d839b3d7c80a5bf8faaed5a5c3bf4fa33302732f51f1f4a5609669336e346c6ad7552679ea6f7522c790213d21d80947e97f0086115d09ae1b961cce9a89b8c8830dc912d36508aa0503e5ed060ac1c50413406cc6f2f779c0fdc546331f539eed49bfeea50165aa8cbc17d3e623da5f7c8b831406bee77468497d4677ab156246e84f67fe6ce94f1d03f169096ee5fd7a11409c46a7ab0d1c0ca19b5752901163de18b1010a055f9488108d3f0f90fed7cc34c3c730d15e3c631f88c61254f8f5637ca400efb9bb7618fb4176963fb33d8130ddb176158b454c04282780d0f8e0fa9398435d6aa6f3f2059c421fd5768fd039204f4a7c4e00695b86d67b5a1fc5fc614249baf0708e9b8908d5a7ee7345e7ea6c0d285bfba955e104acca0009f31d60994d45aaaf7b9634fc4a8669657df743c3f01c884aeeb4c16d185cf96f27d5ef2c619fc4be7ee6cf2c55e796f58a60bdf57dfe4463fb42aeac6c5f6e8be2b3d7a8c0d5d53a6e3f0e0967cb0d1bfb567ede6ae0833e8525fb7f8eee31a1e0001046b8fa88198fca829367b4d75d08d34157608e49c72a5b9239656a8525bed6f4c349ad5c535836ed9c0f0a40ff37a65d0cf29d85b27a54ff0a1b0de9f6f798f4ad9c1427dadc5e21cf0c5d9a8cc5410113287d50c57524ab4911ea779c1a418ae8214691c392abe65a9f86be583db77a4c9365f997a1331d8d09603fcaf2d187b723a6fdca5558a00ffd1682f052b375481031338a5d65f6189daa0c8ddb97fd42cf35678a2ace3f6c654a7552cdf9a74b399ddaacb4d6f8a6d8502262e551c55b28979b185f40c86ae4e04182291d5448de1bd707dca712a99e39f4cb50c5d119927811ae4c9f4e90500689a07c7eb4d54fdd80d4f6466650dbf02b71667439a988c8dd360987d7ad49cdaad4d4479bae15572b0154c4d82991c5f5b8bc0dbdf7b110c94c1fd2b35dd11158fa386cf75302e4490745723c6ad45a95701b59849f7576d1f2a8f3579531de7512c0d54a325b86274fe36118d6908f8ceea35c814df544705706d3e3a908d83fe214dc0d68730eb714dc1cf7bc002516e988ce9fb91f9492968ba90b864060021f9669c0cde3ee0ae773715d64e9a273f2745be2ae007ba57b5a575d9ea71a83fdf2fdbb8f258b1381de89d5a1dc7cbdefad5d3f86ef356a88dfa2f50117a5fcb538c6eee282ff6fe43c1c86ece69fb96ebedcb9e37a9483b88a68a3ba44909017028a7fe0ee4c40f8748124aa0f560f16c309a74ff6feb03c0727aa50839458965796736a38e229693141817a0b1e2771b1afe5e8a267d231b86813193c5a8e31ed491d532d1d206e0b2f65456989c585eafdb81a5c2384651a8560dadeddb54abcf41703f5019677fbfdaba0aaa9ab914d19532a237895b81f58b503069467be94cef4dc48e453bae3efaf8edf608641c01c4378bdaecb4524ad9141a8b3ffe830c395c5485410b0392e9b612c38a0cd23cbfe5a611ce3315fe0e77c73463d7e4dba12afb0cf349fec98bc632fbf7a34bacfbd45d7996d0b2e7550b994d8c4bba9318862d570b0b08260f8ead4baf51be92ab553e51693741ee70b013043d948a964e848b01f893692c3d168a39a4d9080ce2845eff15f59541105b2380662a79f521286f851ac0916c6c9abec100a111db2c870cfec1f971d88deac705620f584e3ed5b4fc70d003714a444cd9bf31bc20d96ecb5d04707063ebbdd64971a6f88a420e3fd935a7e5d39ebfb2256095cbdb5da0e67b0d581f987f5e19f379035876b16eb14fdbff587593a931be3afdcf012c0c464d02b13b931e9d070c8c875bda6a40b821415ded0dffa2d31c2a9b664b6d36c93be54973934ea15347e54180cc0793fd189ae0824a7600d3923ef73eda0a74969cb430d45f7cf8e7c438c2a07f7bf52cd8019763dc2b1e4f52ab9052504bf2c464a071cbc8c04e8f000d72702216a1f3a1c807c3e26066841f4325ec8e7c2eef0a3acadeda19855d8aa426bb42a78908005658f990f14c280237fa2da59c655f9df869f9c32654f3a6fbb4e6b70d073b4091d10299cd588949ffe39f894991c3d9f45561b4df92ea53cc238345319cdf2adf0d4e876be96ce8dda60e80781345d03373971af2537833c63f6598764b40636e12421d0be411fd65727f4cce9593df2af0fd776c9a2ae87d0a10d4a2bac21f1e312f9ad121bb8de2f5a9db76180237344e9b79af461fc130f02ba6745cd940ef9a12d13154f2a0de08fb1bc4fbc10bb880be501ba4ff81aafce7558e20d384aa6e62ba3363db4ab68ba5b57b6025da93af362d30195aa3bfb37861b942b6b65579f7f6ae9043aca3d723c326025e2961b60e58ff901a30c55e8169e5eaa3dd9af5d50c35cf4a10b47c07754822a0d075c7d0e93f1188452daa461b94dbbd167faba1f9c2170b45baf6960a75bf861ec1dba195519361ac09a89e59d60ae98c0fcf19df758ff9fcaa24821baca120ba62e5a6665eeb0ff2b0ddcb0c95d28e681136a06ab5f1c79836b7cb343dec15e00f6e684db01b05eb6b27c25910ae34435bfb1b1d5ffe7af67648a544feb25da71eb206f186497e8e176307503a7cf15ef68833bebba0b5f743d03252166871a11405af9be165a224aa078ea1f8ab8b4a6d07b30b14744fbb8e829b31d94edc2fb3ec8859d7b06578d1b5653555c383671c2a2de6620b862df89761c4ade4e67edd6a13f0093ab6c8376950029e97f3f4c37ef8deb54f04401fe4465f09715cdbdc7871e650a985a31633ceed2d4d39918dc27771139ec63bf41d3072a38471b3a1bb81d44d1ffb157ae43752389d3fa33a908d64bee606e1965930aebc7b8f6dbe61e0ab53249255f95acd073cbad61366c91fa78f9bee871f12465f7c85155afb61cfdd3effbc23fd6589449f958f86a14468c7fce83bccf28d71b1d989e6e437ea56ea8a187070468ed606ee01e69b4ffe25d25ca9e70cef59b04fa8d0f41a8589de564b2bda5b490b37811feddf30b08225e7b02f08e548e2a754e419d9f9c703b59fc85477c7b3c1b3228408af65e7072c9eaa3f33cfbfe36ce34540772d33337a3157008681978fec073c6ee17b2dd09d146219ccada6e8589d81e66564edd7424ecd88d04726d1b09a947d7aaa389754aa2fb9eb918e15a4ce4dcd24942e70c7a28e5f35d0405bf75e22b21b1c195159bc0ef44bc70b7d50829782234145e313a194ef2deb6b1ffee6c8e9ce4323b11cf2df5669f80229574a6dea9f8da0446cd5b4ac7d3d5eefc11ae8f60f0c21b8c68d911c0a0806814f29a3080e0e09b031a7b62d6d7b730dc796bdf93152e2e23fa67aabbde913a0ee35c97d45a68df0557e5091240faf7cde5d74481d384fc982b56ac14b60a3c91a46dc007afbb2c17c9a288ba77d193c6a928407b72f095664200d8699fedfa1b8376a98032a42c2d1cc74d47c0d61b4b0648d6116d78b77cc58b058ba1333edb4f36c6ca6aed1a622ca1498b8fc7000d8d9ae9f3d53ca6de4cf0a6cadeb59aabedc739bfe838a380a131b1763f28c09224ced2efc5653aeed507bcf3ba37332f3e23404c1eea9c71be350c0766a0520eda72992c37cd31868951964c6fa4ecff74252208a865f7cb59b27d48e99c56ee604b4f979ffc2a3382d087fdcdcd82d840a68d9561566fa541b8384e4f4501508ba5ec130d6d42b725316c34a51b9c1b331aa0a6bf794358da77b3fe9d8a497c2359c9815c8f6d61eecdbd479505a85c8ace434a00d5a3d39f53d6fc22a3c9e7cc8c2d7e1b50a5df7ee7146cebc4b73c2bc8b1da11420c67e43edf621723c73bcb00648c271df4be6ac80c56d028088960fdbcb31d346e9a5e4715adb64aa83e97b5933a9e99353869cd4533f0c40358c5f4cd904ea20857220ae8ab5f6c83ccc050933229b7ffb9cfd0fe5dab8e2989c2d2a4eb43607c06758b41718ab9d7a5d640fe01216f1b853e41b4d23b8a3853e20efbe8897cc2e8aa05a93aa21ff06cb6fe716a9c6f356f241f720d8d98838511bc185ccd14383335899213777a557fc35b84af13cede85cbd38bf0b6b34557af542e6417f3a5e9f5a7abb100145bcdd9b466ec1494c41d684e6a8ab98fc144a1f3b378a79ae882c08d4111b75850668324de80fb0e98e5083340c970e5f2eccafe5e30156bea564ffc24800594dc61d47ba87f9279f377d57f2f736b40ca2ddd5f993e74fab90322689164aee6fe8b5dc9a0c6192a7d3d741395eac165f6742c455bd8d203c301bfe63bb17cff1cf39a9ad398807f81198c7c393334a832f9d71f08a64ec1a9ad2fb8e31c217aa15a7fc87c333d0fe37191211e25df5453a6be6f5c28c47476960ad5d3a56263f1cb75e1823b86ebe92374e0496e17c5c44ba0fb204926a8bd16200e7016de97351cd51ec008ae2a4eed3d83627266fd53c928a1d58b1cbbfa4d2e8e5b9dd8a37df0fa80b125db851c09e8a1f1313702618fa575451e6ee99d07cf38f7cb4a92750918944e90fc81ef9376ca2378dcdd2ae6ff40a327c6aaf4dc24a2731ad21a72bf234fb388be653e52e5ae893913335fde0c538247f0b40a898e11d39652c42c56b6894234c352967d4a3a570f4fcd2cf0848e9c939a235b733793200978838c11790238d28fc9f83b2d6a92df376df3c7b451683fce1474730fc1e15e7a29afed659383d6a4dc3d2f996ded4267891413e456ebbb32fb765c40e1f7a3f9a77929b6259af4199fb0b2f5d7e2da12bd184a00f53073e1bb1b7a9368bbf1f657917b697bc3c2f4d5f203514c37ac63af1d1eaeebdad0e24cd82b5bf0b9fb292f1cbc4c641b85a6bff4e141c02e3e86b5544f18eee386a3288ebb5bad78492f3c799ab00643493208682c65083214a8e433933fc91d7954fca1d3f5fb9c3bff4c8d85c2cc827bc3db6b123fc2b5e680218da26ae0301e0941b31c9e07da4775432489f32ff143b82909da0a05b3e3220fee475a53d993e287b54de59eb1290d321e36ab24fcfd760e34ff30b6374d8cf4967e195bc94c8774f2931beeff3a10ebff305b635de442d67e37d7263e28a2c10bc06c77933a8381c9e7b139118f7b5bb571a797d91bdb937a8d20939be189c83ff5e41f55ade85f92a6510a61e5fec0fbf37ff2a5f35a0751ca3f7913a81447b0ff813b292a08d33190dede89deff6e53f83d078a3d10055bb5443da290a2596415ed68c396a0fc52f3072395acda6932e6bfbf763345eefd308bb4fa92f43dc1dd7a3afa3ae14c5ce96cc69fa1075885a68a51e8daf2da38787bb38ea9b28520ec1e3408ae53140c1a6f6ffd5f95de553252015cc3fb982d1e2e952aea9b4607589393144472fbf09a4cfcddaf56a24e5c4852b9c626fcf43e46c4a15ca9d8d2859df958655de9c427e255d519047645f15cbe9c18d70b822b167890fe5a99a8df3e146ff00f99639f2de24b51c8a38e152b970d95c35a0e42b28882adfdd3c796a59730c88593d1c578d891ada5e12b9f2de43a15f5321a7c1a053d7b9c1652023f0fafe1d9bb615ebbe847762adc8b0a684c98f62ad1ef54f454eb8377b027574b94797d3d163cb851ea9648ac22e93ab6ab25de789d0560cc9ed996d7001c1661cd5ca0b5c4f664ea0d12d50f5ec3c2a98bfdfd628e794283c91b8e8b248b9638caef0c7d7fbac30ef5f271e0a4787532cf970651bddaa0f9f3c7b9f0674edf61f741e918fa7cd2b76d79dbda210fd81c934bf5182c5ebe367aa25aa2b83b9f2f876a085fca19fe509775d6e14961d9365406361901c5fbf57db8646e099c25f6002a16f9ac4035220db1772eefc50336f5cefa1a9f290fc7fb4198ac48f3a07ce4af84721ec846914fd055eb66c4cd97e29f576bc88d01d543dc6f53d43bb9e22e49fd46a2625648a7a86a9b6275f9bc17aa73539530bfbb0c9a592d43b04d0dad00444251a1332cfefa94edf55699229f4ba442c0ca7bbcec34328f025eb4cdc998ecc8913e2ffc125ce7ce6f81b1d4981e1dbd5582df8d3307831eca03a1148eeda7adf042d2606e35b472dfc46c0335b3ecb8a00d4849b9465cc277d36da8c35cac4b004690c29a09082fd760a1a0c9065ef6e93157066e8059ad51712e4718bdfdda06c7a84ab1bc7531157b1333af09cd3a08161ac29719d5d9c99aa4e030405a3f86ab03e2f9d531e8c0480246651c74b05db9a470cf3523daa046803ef50778cd7cb2b6ca164010d96906272f89820f187871dc6a6f03b3d3f5f063ab83397d9bf02abe78922db05be3be032354814314ec8fc8dd748837bbed02fc5facc18159e8e61a7b2b983016481e7b752b978cf127c8f5761946204bf854101e5b4bf972e3f52987de3d9a655c344b2472a68743daed3e66555ef1ef2f43d8b55953923413659c78c4cfad02fecafe530f6114d9fe8f74337a5e23826c0171b6dccd974543e6165e4c64ebe7d2f6d03f542ceaef0394e8d8900888af21620753b0957e1e4640045141afc518c401200595655bb030b95578ed088ba0c7e9b7e0a7552ae5b73c58a11a445a023570bcf44753a2a14d9cc30cdbf0a5e8e80c2551a93445aa4f6b5f599535dbf7c01d073e8e2cbba0906ead1b1202bae7cd4274306859752322335ad074cb97d256f930e94286ff10daec2416e79a07b8e968d28d9605073d01aa3e068c173eda5a5847b7dc5e43582b4f96b05a3099a28e2e3a5a2e2563921d7c040e68b2ee53f9d63bb444538e5d6e69bd8fafa0c28d3f25dd13396000a362243a58cd2c9bba6efff7fbb00fa1d94f850d8890bc0b984e21218a5bf1591fca7ddf1fdced38b6d3a5c219febadf33dd5dbb1dbcaa1cca8a1f566f4f53e1f77cc3112fe9597e1bcd0b5881e7c03b9bce8db5d697c94daebe5c6658b27818b869ef0e31353e1dee0619f274650a406ae7959bda89fece40da28861138c0ebac09ead382a2b124e8af32dc6a65e35e91dd062ffceaecad7d7747fb838aceedf1867202c691efa4af457fc785cae13f284e70fcab6dda46d9ec6a458de9a1822c0e9264483fc70cbd1f00e84281546481a79d7d02b736f39995e892a63dadc235509412382e06934d401afb473917544c0007dc1c7bdbc83192ef16983f1eff0c9d28518ace8a124b7927af86f37551303a0f06ffa1191c9c985a108c732509914472918970c985728fb5f327de86494eac16b0a76d17c8c4e154fb1cfa0960142cf8c5df3c395ca3852d153fa43457294dd1eae92bf7885d88526d97fce11ff156f42010f89892d3ecbb671cc1be7b67aeb3e3c2eda40d1800d8376aa79f55bc4f007318013d13042e272de854154bb9375938b2aa123d606f0fd307466d9c876ca2d7b2a6ae0362456dbd2efa713fe7e9e3f8b02699b9d1261fa7973bfc4946727955edce8c6bff8a1b9ef115360d2baaeb3d0129cd4c0b4f5e6b1d92f85c5b9ecdd99873431bb852a9d6066df19225740311ae327d5bbd0c55988d6a477cd99cb47017d9611f494b59323b008345fd0b5a412da7816ac078d7b40f3ad96ad216e1e9e3ac434193ed17428bba3638b6787384d773284bf92af998835f0cdef84f9019009b857692a9fa98dfc9717e1090cca31c77ae2d8c064d0ac6f86823e61f855494c491853e6500e00339a9ed3b2e76a0bed3e6c0e84d0a947d0c54c0eb01ee684fc807ba681570695ae8e075497fe1ca32bf07232d2bccac4bf050cfc4593e97b78c6a01d400506c8578d519e4d044bd742f244964a743bdb446d1be8394f419ebd3bcea22fa40c83492b03fe87f609e3729f01abec30b1834f51e5330bf932c438ec60850e576394a5abd9a235fc28fd73ba305fa445f931a208acd5986f991005520a2705850a866f6e3617363d3055e78062656f20031cd39ca6b953cde25b5eede7229f9dc171404eb2a2dbd18ea10da959c1cabe47748f1ef2f7ac28e7703de3d7772a5dd6d2fe9f43cef5762ed0d1ff86a8517bb338a194cb54860625d2ab23efecbe8ce9b8c2c775a9f4f01fc792b1513b962e4ce2f64213c217a557755892dbd2d4056272607174a7150fcbcc2c9e3515df9d3a83ff6b7a8064a4a13f46744d9273080b60fd46a008101c1cbaf4f18dcf94310978271c4af071aa1895c23039f3ca34db33595596680e56b0e62030c2079afd690f1f8774156672b98f809e39e5cb07eee7e286d543ce0b8bfe7f71a5e6760c34024a23b566529df809d66fd6c245f6dc49bdc7c867ac71be1a357ce13c592f4fd4640ccdc431fa7a6604ac45449fa9e2e79df60de5c9839f90418c90797c20db71dd53afb72a28fe8f2e117f7daaed92e7d8675d6a3ac147850fce439c2e13dbd0f2fec0c32c72b9b44d62b4e434aadf8b5e4eeac92278156b54c2c8f4fbf86cc8cb23085ede4a7b681dfedad506ad20cc5952e8f35a69216ba82d76eb2189cc64a0c8a86223fbb276debafcf6e146ad85df94c1b191105a58b2bd0e84a6eeaf88539d5a5c22e9b6aaa56389234fd46843de84a05c4a140d66927d43093394dae72742f1a3c29dd3f798d48b8d861c80e0c8a44830a737ec77c888d6a12ba70bafed9071339b36e108820f7a4f5e3b0e0389ecb430ad31a145eda7962adee0623feba6fa0bfc88724d7f08fa854dffff42df1ad616b7496460060c85aed952dde198f9645bd69dfe65e75cefa34c27e5b943cb1fbacc64b700de3027e40ae0dd7fd2e74062482fcb074c55aa1c0360b85baba8adc22003d8408d1b4237d847586c3a2c7f79f7e8f45ae594e7e48f8ac6e0feff22758fb7dc48567ccd45cdca9dc70037c949f00f1f5a3eb33ed19e6cd6c89ca7b9393dd355f6ad057f1e840ee9f38a091fd75fa45ab11a7754fd1e026eae250f40b2666b756d913dc893991408ba4719dc8bad21290398c3b28317b7fcfb05c2846a71abce7eb25f8b0558d92d967c65ebd00f90cb5702d75845caf5dfc2d7c3e2fa8c5110e1930c03d7947e48abfa6c8d415c1e62e9968a7b63b5c1547ae61961a3b90941b333e32d86c9b7add571fbf3e5b242b0e2c1726c70381595340b18722141c957a9392570a9e69f1fd3d82bd98fd76c09f540808296e3e6bce308c55ac87dfd1299684dd70c590b474d6b65c50bc7c1b447b29836ed5140c0d9edd647407bad38ab386c42a02071251853c24f0ddef94d4d83d4d5b0a10d5121c2ac48016f866fc5dc2fd99554e9aa35fc6a35be9ca1ed649941b16f176663630975987b2faa4825aa5c89e33cbd74bc8a6402ca0b8fc1a40364628700565a32748adb418c1457213b5f4c95ca3d10b01e7d9293041c91baf1e04b6626e08cae0698bea5dd22e9445d5839c409fee6f41f6ec5dc172966ff65e840829a020bdae19a51e005fa0241a804d9700169e997ec37456acfc9c267232e0c7459b15a0516c52841334075d84e675419b147d5d3ac94719ad6d02deb19291614dfc4a3d6976bc669ccd7492459465cac440309a59cc5877fdfa0953cf9802d0b465616e5f82f279f04f683fcb69ff431da8932a8311c19d24feacc7b06306b40dd7c89d27cc462e528ad60716c197bfae47ea9ee805325f1dc266fd8ed1ba542dcdf903ac696a0a89c271e83121c0391613c6a432eced451f0ee20a050ed73cd9c08c6189e4df2858ba789a09278f8dcf2e1c3b5aae00bcfd03b8d41c50eff19f44bacf9be3a6cab08ef8685c736a8d962cef51da0b5587a352a5a2ef3480864af0a56e6bc64cc469dab256d5b90e185a5d6e9ff409503e99d9acf78bd9cd7ccc95c91912d0a241674d2b89a19de58767be109b03adceef2f36f20cd69d55228e66aaef010bebc5762ec87d1eb05f5cc102962e9c0106a790c0c17511c58fa3866d42cd5154e8fa8655276ab78092cca7f04e381be4fa91839e5503675d46fb299d1e29ffad62536f78788b600ba9ca52a5d50c215d2d6f12df3d0d7a506ef6054c683066b05a91830e69d40e28479a10e0d5355822e7b03f0d9780a7075a7e4fc1ff57a4db21b85f5c24b5d2fc91f24df367c9a734f441d09eea9554b0b8b1546ee1903881e934b23f1f3bd9aec8e311b1df35014810262778a6110150098bd54f924a5ba1c5892b9dd94cda63027a5f36343cdf813a96a8abcff3bf4dc24a9d8212c7e22191b03b07d4c079d252b5981e07cd92fa227af34aace1cca959e105b211be62d0d5ff3cac1ccddc7566b7bc2fa7af547d5243c6e2909c252cc491185375c155839eb5bcf9aee8cd7c3f0e0e5a9a3ec16cd5c1b4fdce87ab1faad254dc8aa491067c363c312e6935289f70f36d3ccc5864011af74d72aa8f1fcb05fed3e31c93a741664e8c756f67ac6c0852daae4335ec5cdfb7edb82155fbf43a86e9c1e029107115cd2979f4e52e7f1064bc5d93f6bc703fa9c9280528844f51f2e3534ea8b15365fa4e305f03df80c23545c9ae44cd496851551c34e0fbfdbc20f1ec5acd0df8b9350a5763680d40b4055ffcf22126ac8c918db6d83e3447263295444672c2f821b2132a7b70633067c6d3acb906b7cc6b4486f11a914a9d293a7f62922507587a279a49f0d60fd2a52381e8586fdd37dc7cc25683a09f5b10ad202709e75fc40a4477c9a0ed15f88301cd0c5746d37bceb757697a5645c8544dae9594c2186665e3a265888289e1d4a45adffd560fd0093583a265a48a26ce3986a5c7eca70c58595b9b25a452581bd79ac2938914ef9eb238d97c1ade07da1acb686209d190fc48d833ad8296a3d25cf5c6cce9c79df0b659f0d995c4fa954fed2121e920d12d90cc5ef71042b5e7ce6f1fd29140a0e3ebb1c9a4f921b79456ed0d67df4f86f999e0d44e24c13140bf4e6d5cec5fa9a09cb5f4e8e4bb2b790b60b5a6c8bb9e621096719c9790bb83cc3701e88f51682439aa4f66858ead085010b1402d6ab2bc6704547b4f4f09971eb1e98da106893ad8f6703df2afb4249ca75cf50f2a4cab95ff99c8d6964afca42f66f7d87ea33e6fa37ef911ea45b4d18246fcf9074c8978c50ea29ce1414ae514b9a97a7e8781ca5644290b918e21686c70f8c056517471107757cf7d2c8b2ed01c590933914d227c680b38831fa261d8162217d003f18a8eab979a3e884c9de1c780fb89643fbddd248975d1b820e25002410170073d1537f82d6a8811f1bdf6317ad4d61c21801ceffa299586d5ed2ae466aa265bf16246eab81c6c0fe930ba73e8ccb81f96c6be1178bd9c4dce5428d3c8e139c54b03683369a9ff0f143ec402f7ecdef8816b38780827505e5b53b7a84f8ef15290d792954bf02f3545a96659b60a69d8f1f7237c561036902dec65eb90595754c581e08e9aa4d8f9d291c378d5114726b6129a68d59651a7e8192366253fcff2b02db84801bd1914053c8c2db1c121d70a2746d22424ddda0ef5b7198fda29c4fd36fed9abe85c4ec74d44a4bafc73bb781134d72587d702b96634415d506fe708ff2af93117112d4aee2ab7c161f81f908e32f683e1eec6abdea5e79577555245d1868d31a0f0ed1313584955e057ed21683f57b55ac6bb499993fc817c03b7b2ef0f4a26c9f5fcf2ceec2dffedeb89138309cc1e1658587589a5df7c59c1e1a0e514245a738a15611491e826b2a2450b74bee8d1a0bf695582dc24899bbd7a4ad506a130be775f8749b0d2de07604f7aca8dfd574b8e572b204c2d36f11015ff5c66b86f51fd2203d1990393819e24b1fd92652ec8b71161ceba46ef03b0774171466bace5ddcab81522e6b100c910f17f019a134f7fbfa34add8649b69c73e383e7875477e99045b555e69a19407e4bc480a6e22bbf8d8ea8b18f539e8c1c7350de4022e9463d1290ecfda15757925d8d6913396c84c9651f2e299ed09dd3b04a6b2b18763dbdc0799d62adfc1e65d025c55f5adbb7d52f18276a0445bdea94ef853dc70728856c7ace3e3c8cc5be3eeb6dd2e1c0fe46d8176ef871f89c8b801759aca2b762eaa58c527520a220148d70a19fb87765aa53f48a3c5f775a58bab618a55a03218f767497b2bb03e6925069f2a1c8c6d33f2b9ab16fafb1fcdc16f47e9b3c96ae3c21155753bf07473b01a5265880779b3dd8f136332bd04325ba587ee51a81d62f8327786d3c7fa25f0ab14ec47b5b3640ff7616dde4cb756f00b6bde5839e4c760fb4f9a08d1994c683d9a30150da42b97e9f03c8f19a491302f37bfae52a0c814132a105fdb9a6df7252568f27402428d7c8d5691fdb17079c589d9f3d492f97cf91fc5618a937c9c21907a1e9ca4938cbd1c72ffafbce3d0876e90972f0507849361a5132b13c377b55fc5eb092b91050233a3140f63850b929ceb7b136a1ddd3a9995a97b2c8d76f7df81f39b29d43014648204cff4429b595f9b19f7ef5ab607fbf984dee0b7ec02e12c527ae72bab9095b88f1f255d3bc3027426d257e63b910b1f4278388d7ee978e7d8113c1fc0007bcf341e021bfa2e700e5b19b30e0b03df75b6d48025f36bf24954c4c4e93c1c3922398e9d79f725f4dbe1ec09f5dd70454f8bc52dce313f1c49e3720e2679b05c32e4f0ac8276878c976cb500e516c4b9eab220d56b0185661859a4d7d8993f2d5c140b79d4c740578c2c182eb4bfd1cbc92aaacdca3c385907202f8abb1721a27262a3774ee7c200710ed02604113f88e38fb6cc3fd4236600c3ea72079e3755cda5a90688ee3f79e75fd710488bd76735b11ee13f96d132397fc0de9ef4b460b4e85c2e00f16aa7037aac5e850982fa2aca4be711d450ec2e9c778d1ff23045641d9095433eecb4445b45c590d08d4b0cdf99a0ee6aa4f86d137bdca5e7e8a456e05d62fa02120822b952c8af35a46d78831a77872b2ca948c45728f9e58382bc37105390ad80db51297699165973ceab7e6be39e81bf83c9a2b7c47a6f828d6d3e593e431b3765fe028cd0edef44fd3b6fff655f2bd56473b9fa7bd53dbf85f9cfe5661c3e7ecaa002ede29d8d0a42dfa1ce436fa36334b2f47ff567afb6bcba67ee46cf9f7374f829b0df1f9bd84c08105105c5419661b8843d67bfb3f7115a015cb22a2a5e57e2fca0e4c1844aa27c30953959d727b8c2b746179e76a64155ef26faa8b00dae331ac167144b2c33c44c497bb471c085168f6fbcee3e5ea1e4f4635e7204986ba745392e5d3afc10e19a43e182df42d5a35971fb8f9f112494807c4c246a08ca75d118ede578ec8bf80f2f6a091b56188de9c2537be142e945a1e047d49a71a8f58d1e2f2a742687ad77ecb8d8ce67a26f2e1a2ce463c151797bea4ffef3778b02cf01bd5b7b14b5e576d62bf52a020320536e8ef6c191337fba7fcf57aa1a6264404a84e018311574871763c74878aedc0cdbcaf57b55efbd9dc12d8dc0d1595e79b40f52bf961413103ec12ec672a8443fa4194d7da89ebc8b49c97d9e765a830499cfdcea0afc1974d99c501e28f6f681770655d87a9aacfee96899a5af0d8ebd92fb788218e4d929ab7a0f9fd4b43ba7e0d49022e64c3a732f49e7095b57d6edb974f96aeb481bab5b9f6c403b1df89c70badd4ab60c1a29001a932a3456c6fbf10e0d746eca19918db3c4b208075ac4499e2cd1e14f5c48089d59bf0797ed657795804cbff339184fe8e0dd341b457c716e339269bb58507cb2c8f496de19f5fee2c161a30cccc999461ac55a716725f8c4473c4425cb1c91f7de4271f36d20869d079db1d762703e12be8d1e8de3ad62d956597b28e199754263b00797faa4e737637ca7b53e119ec9f5e7e2e68ed44819c36ec3093d80e21cbf47d3178124f9b063fd7356620e62f88e194361bb33e73f0df2551c671f43e871897a63f8f06cc37d8a4b019696475c89f905f0c879297adb073797c723f1db4f0972f1060a79991c285123f00c442e6b427475f06a4d065cf5c32923af8ae0d9bb69be1dd86fe85f1bd473b9713d695fabebe850fc89d222efa44fac69f5165e6a28ed42a21aec50e03a778f1a0a3e45c661a25f58755eefa279dc2810ea0726293dc92cf68ddfeb61a64c08a213d5d2b649d8fcfcfc32b00df3a47e724199195190f18af4ca2a6ba5a10a0def221d4b04253f42521db4771f234edda6416e290baeb093d4ae87b6c80a527fec677a56b25969fc9a16dfcc8aa6c30405184bc6bd332191730099141790f6d54d5353c1e8af94211be986d84dceaa8d6f93cb8489b6797900e873acf4ad779c29ac5c684c78e57d33a6d5c76b473d03a72650029f28084780426493cf2b3e7acb2eda7d7a83a51bdb114d5f954bb35b6c2a41245acd67cc01286e3793b1d4cdc8b0ac21bdb8cebb227c2d3807c8d3821a530777da8d4cbb57e924aebd46832aacb089c91bba44292a404df6f39a56c660d77f8394ab81e2d2e067a6d18eb8e3fd052e2a508609f46b460e03e9e661c0a49cb7790218a9ac22a09d1e899f06ad1797492191e202a4e3bf9e305afe0bde74b7763896d93997370c3bc3ef862a4536d8ab861a9c557b08a82627fe55b83ff967c6825656d6f92ba292af17c07ae5daa95a51e2cdd5c7be297c5e561efbe105ffc849ded3ee0747c6c253586675a9fd33c1d688ef25004fc61a5d65d8ffaf7dea77ce1f89299c03864a43334c430eb0e5350ac076d7ad2bac7a3372c4fff64b1096c2d481ddd053095c309e33e5c0f515fdb17e2932017d6b2d7007efa2a6a005fb6ef8f18b6646353fda30d6afd3a617cc5efd78c5995fd977a885e976c81fc9be4dd92994739517d2bf2297dd704480d4e2ab7ffc37cb49907affc4942adbc77894a89a5284a65294cc886f1f0c73f20da920f5c38eb4336a6d29eb4a350fc9013d3ea7d43b585a819c61ae2bfffa4c8893a9924d56ac7d427cb08221a868af90cc5427f0a090864590bfd20e3fb22b30ff87d3617f684da287fe12d2ccfaf7473f9ad276d803305b5c8dde2416ccbb47b7d12382da22fd91a43ae9e90511ec31a11b4acdbed3b84a400f7237cd200d8b2d39b7b903df9ed982651f5ed9baad3fbd27e4ca63a566140c7dd7dbfc015875227589de975505efe4a24b2c56595dae8bab0ea3bc5827359b90e3046621a5cf058f942978c4c6bae3ab9b0633007bd198a9d9a6001d19bdaaeb0306b51c776ceb70b00fdea8a22646f3b54619c5371852206e3e7f7fc29dbd280cd4704d17f8f5e7234e490662a12f797c092f27bc301e5ff54453969d7083cc043419639ec49fa15c057da15bd84b130808a648f29d82b5c482d04395a4297144697eacbd9d103780a0c29bed27c0444534fe8814292c4cbfb2cb9804de8b9ec887ca4489db851178b99d990546b1acfac366267fe48260198f7e1bce8dd0158b7dc5dba3ea632ed53768aa14d872c33e030cbc6122a2403444cc37139ac7cf209c36e121517db4e344d0319ad43a49b75a49b5edb30a4c68e8c521c8b50e5c82f85e6240a941b5c1a551e1a039516a1eae944f68422758302c5922d6728956d04fc5b62996ec5388072528ffc1e68fcf20c5c22a6c2e30c21e0e43a822899aa478182d5a387b1fd4004a13a58bce9fcb7f2ba06bce4ca7201d42a1e063fb0e9078db037fa92a995680f38f985a028371ade798572bd4f7cf0b96c354865877d886767bcf5b0fe0c3698768f7b7e3698a5c7be6ddfcf7edac763cc1906bd127914dc2b37880b7818b0e02c14f3c20b3f69b873e6d07deea06a19a89d0d8a657a9b015b4b3226fb13ed03be5c03cac94ee7b57a7cbfee9fa6ab0cc242ed648a4cfbd514c1150e595448536e2c62bb545c97e2c02ee21c8df62476445d89f602c308389d07ecc9099f3e642f6fbdfa288b24cf67d4c8507ae125d66a5a29ad683e8449fc67a8717e2273ebae680c6845112cc82de3516e5e49c762448a9502e16684cf7cd1f12324b7038b79307d7e2ac358b65ee52debacbab174682c545abcd936c88be3b9d0149414edb816ca8a23deb6dc0acf08413f88640df269e96fc81d4a8b62691f779eac39b081b62a800a2d4040d3c750cf1814a501f5f8824c31fe96595c161705d91ebd1bbe485e2ebd7f56ae5d0b2f59aaab1168bfa267dd88a7b20f7195f087511ec4102d3706f4104a8f1ad512c0792abce565fc2244db2769b0d385d2a52c26cd8103db6edbc01d6efc1c28ffa0d9fc4d84161544b7cc302601c7d4db3feddd816718fb7ad7cb4bfb28f4298507fb6a10d58f83e987e4ca1defa694319f49f8005bff4d12bac75665381023ec6af29f4a42de520843e605b4e690a62e9f3fce11697e956172a16fc8cab2768d1922e0eab13587e4905eb9a46b9e47159430abc96298f7d2b1a38ce5145fb9ff0558ae5298c5a5b0f09a2d54be09181ddce87be281447c5e50ff25ddfcd6b0040928c379eb9c3fe0fcd1161b0f614671ab14a1dc0b1486883f11bd4d4bbf89aa07539300ee764a9fe926d95506c85f4fc1853a7f3d4817959cd3f83fd8e6a29fb64e248271d4bd01e1b0dc1f9b35970c26bfcbf5a4a9c2cecf45ebce0c088e4fdf2d2c18aa661d83cf04d79ccde2bf44a3adb18cc2a5f3b20c330397c5fd498ae2b9d02dbc3538c65dae8d54f8561be6b15bf9b1ac2ed3ff6ee7ed034dc450eacb6f2c65fe35929599cce600bb5d9c8a8856016bf9e18bb4a63ef603e9093ffb974ed6998910f93bfb31d276572668f88ce6688d517f32856b7f5b9cd0d713eb30959ce8e492969268d42340adb068e900d4d44c67ff9bc4ce49932e608e69070667f7d44e41556292866b43bdf5477ffb3e9346a207bb3ceb83470602359fa119fe5b785674d4b0c30f1330bb2848d1e27a69d29f2b518c9d0bcd5ca4822b1f9ee3f4ffeaef74162d315851c03c60bd8074fab6133b787996982a055af764b07ab367b7f63404349156050bf2e8e9a7a809280bf3fb90dbd503cd4f47dbbb91d667072865216290e346e65112d4f103118a3bae32a3f2f8f3a08df70c6aead533e873b1ebc789275626d9a3351b686576c6a713d30c41e482401a6bb4db66f3c3b27f34ef25aed82e898832c65f60317c3ca14e843b41e38f67073648a1a7cce39a751a8e7baab595b2c9a6229018336d7c55627836624e65f4b6f68ec6dea266bdf15c92944664fca5b2f8bc39d10b79c3b9b52739223c20e4a37b962a81fbbef8184a9df2cd86f3704bf7e1a6c2e9e049417a5fa9e9b2d1b6d551efcb1710485262fa2d98d97db717beef95211c632a7aa9e4a2934b7ddda179b7c031fe05235db9b4c82a0f52f4d3dd7b1cf28120b530dd5a2a54ae00bbcacd210eb814424f899198513b32f4cf61753cec0188bfc0d7b047583f0276777e1a5d1cff19d9ac5093b173e81cf03789a0deda7b0506b5c5a6ddb85b3ee386419705d2acfde3bd9c63ca894e51eace40a9cd4057e4a0df6d86113e8ee46ad2c2f87064b15e9043a1b7ad90abfe89011122daa57358289e807d9ad36b9ede0476a724afb99fa1cb494ba7030aa7e9c080f31f309d857e9ab5ee0831c2657ff7cf1d098394ff106d1915c3576c9c5a6463d397b8b1dc8d3c0a0a0e5aa1377bb551ab29418f776179ce17491a95b1a553bfca77999a966be1be94013eaf8b9b5c0b55da01c8981516adc7e71cac68e9ea0cb1c1266fc18a40d72deb5246a958f141ff1b3e4c4534474a5a2b49854952655a3e5e7502d47b93dc469043ab02be00fae729147fa97a6ed6283e4320c278ad9d8c9a58aa4796628aef6956705b22efd3a7dfcf32d92d47cd197850634a2f31b381f00fc0d8414750d15046c6d5ada4d1665869663a151fb69f7b3084b887fe54414b5a016e416cfdbeb079a4b32f90b28e03231b59585d161c52b47ee081c95e306450957e04975163670e0e19048387661df075c7feb03017e5a3fbed1520fb9677fd1c974128f615100fd172a624801dbd234e5b11f5adcb15efe2d5ae0fc3d8c55730b5fd7ddcd3cfa708e6abea547a3ba28bc561b20dad311a0a791252c0e57fbbe94f1a2df03fe621adbfbca526b28c348c72fc29804b1a6abdd2cad5f72caa2df8899cd81a2ee83688728f0ff9b96914f83b4044af947c810aa3315f6109688f29ef266e4971e0ed7048fb1af82f92f81041b747f650fffe72400deb4023a836498564bb9e033e589048c417af0b460cc4242b0998652618a91336b78fcea2294f2be1d87be5ea49793ccfb649f3edfb6ba01c30a485cad2a515d8e17230371dfa4e237d2bb675739f00a7c40cae425d6eb7fc7df469889d6ea71bb9e9eeb035f92d2d773a24e9022a4bf94b70dbbf06fa6de21a590c9aeed1a3ec1fca30d6db49f7414ea41c9b1335b0356d78f6a921036dd6c575ef54ceae75d7bc55ccb1081e07d9d5ab277087faa6cb59d9cbbb2af0566589647cfaebc410d4ca406f28f346f235eaf0a01c2a23dfbaa4ecbccda47ca6d49e5a02a11c84ef2e3acb49a15c4c02b0d7de38348e18233982eec71189759807091e5e492b0ec152a068beeaed29b3e5facd2bff28936da6e10ed1c4134d3fc17e0ec7e3883195c6102d065b6d38dbacb0f9099760194358793659864a80ea3d2d1ef361047ad62bb1e70706f7f59425b4cd7c00f829f1bd8cc6349d37b9e4d32a7d6339fb20a7c7dd9076bcfe1a14161d103afef1b9d1c260cdc01494633c262ac95fbb56caed570ef51aea99b7afcd8b00729c8cd3dc3063a2fbc0b8a701595cf6c2c6f8d3b4f77c42ce9894d1ac6cefbe7458d311c50977ee2efc252ea0ad10528b2799b50e6f6edf67b924b0a4e230938c1bdc2dc516ac677ef04871f3592295062f9fd229859483ba8421ff97e081f0d6e324cc0665eca9defb4c6d1484dd62872cd0b034bc0118a673c18c4168a2c73a30dd5ee73f4c5faba25bee4f18775800a822e229978bcf9fc741a63110c5935248677251707c40353b2203fdbb63d39f20148973e103d5c2eb17c82cc50f374ed05d5a2517742565a1b60cad2c9585809efbc708b9c76dd80a796e46e595798e9aeb22bd722359bb5a36a4e52a9d4d052382caca7ff0f05bb4c15c4750693f21554d4f6a65f90db1b32067f448f0b7e221751acf44d3b131518dfcdabb018994103f2e1b7b42adb0ab0b1be18004a05d16e5ddbf8373c382826939c3a04cf93cdb9d711df7561e6c448d45b34de805d332d6c6e8870089adce34fb26ee84dec97a5eacb9f9c46be122adf51632c38b5d8638ef1dd91cebd31cb07aa6882c75d17f3f5362c68a8a30469a07c2d3f654fbfb7e7259c3b392271b5fb55c59294aae10771ac8cfc46d2fe7f2ff3df444d75a0412284b7d97f586c64aa77f639fa7a0ab65c97892b743ab7fe61df2a70a69e3d49384ef13b8e5046711236056cd2e2a9a60d7c4b5e11af2cabf68c0c70b6860223071457dad7dc850de2307a54c4d24f217ed5ec6006778509c26af9f573a6efa3c10ce584559c254c1a607987bc3074416a7822adcb4e9b6eb4b3ec8a3ae6699ca4b7bdcb5463a0d3d5e755027706fbb9c421410afe2399ba03b318192fd34467f3c306143d6d128256934c8fa91e6a0fff67d8d1a5e7a4946cb14a23d651c4e95e9f5fa48d22e70b24f64d0eba6287c9033ec21024c9ea3d28185d25a2bae0c13945690c06a0d49d389b61e43a5f0d78141c313ac45c704843d9d45da2a6361c91b0b7c05faeb9156ea2faf02de404515bb6be0de1981daf77429e87b6782104dbce988b3a0cf48fbdf7f7839ecb9f3f685f074c868fff0ab6d4fc25867741594c8ccf3e0f1be4859dfa70c474a95e4d13b980bc6e13c4b911e3030487c6f2a6a745cbc3cb7028db1e2fd630500109e975cf76199e1fed090ebeaddbd6d92412e5d899260f1f32c8f9138f6965cc942bf3ebf79972d64e4c67b9ba2738d1b6b7d305dd2c9194985be18d96e8c63a984c2e81de2d7f9f8e2e7d8e335b1ac1492976b43f6c0ddbdde68d33b5ec292c915d6df0fa1fef9229248bfaef33682ded270cbe231dc13ae96feea81e85a2f55143f7f837a623ae01ca0685353c017b6c5cc70f7ce0c354d1bd9ccfdff0200c91712b8e818605a297855905cb07f6b83a2db0867aaab8d951cd0aea04337cac03a147db8a72cd80826d0349fdce3777430746cc30a13448af11be93609017f10d51bb2971f99b25f8af2ccf2246914e25824b986dafaf26b3bc0f88b9c977211113dc718a92c61756c48c95a2b9279230fd5dfb95ebda6e528399a954c43a7351777fc94edd6f36ee754ab81e1f6336132105f67162fcd01fabeb9c1642004ddcb7821463b8987a749a3d36ffaeaff018c597e00f808a5af30609759599b86ae12ba27fb02485dd0c639df9f4e9544d26f2c3219a99e1758ce48523d704b28cf642b00c2101ce7e5c933d7b2596fb65691402ff819a7c729fb6600fb37ee5ce8cfe9e79ebff436f41e5892b9129f5c6e660ea5421105001d92e64d1d18fb35a15d502968e1672555970c276daddd93e34e005ebd7bed0fd12a105af6fd082a3053d57043ccd91e2d9f4e8a90a6189ce7514433945a560a0fc3ff1b3652cbc4e4569fd0cf45d86096a9badcf8f41494361aa8aaaf1d8cdeddda49e79f5b8e0ab2ca55e7eee57c0a247b5c6d8144bd936b2488555986b42d00c0f49e16fd255b99d2ec2b8d0a3c7a371fdaa6138d6b06c088bc73e43af3fce2c28d3f06fa465eb245e0b411622395470b0cf9eba05c58fd1c155642fda8923ed85732a092e2d3c63f9734c505c972bb560c8374b704bb9ca73a6d117694db81d49658bbbb86b39d53937ab5de0d1a6b1200461448d1e28ab126ace7d85494a726bec068e79864e3f17635ebd74b7e87ed9d99f08c8de357a92ee61b9eb2ef0bea0e36a54c3519b3ca60a109e955202f8a757c3b4f346141bd7cead08e3745d21f150a7488eb6607f42bb3d569a31585a8b1855bdd200012d1b961c1925f29ddda3e3b1a82e432886292104451acace8c03a28e3471fd174f040a442724370ce74d1b2267f5fe19ce355b7874dd5143c68f864d76c393ceabf5c27ef10cf22c743e473fa21c1c0345701ed799fd3e07479be51c17a38fdff7aac84bb94c2ebeba35a51e44d9a7b24bd422fa634edb55c922fa5ecfbc354df7bcd8b3c748f027886788f4a106b06eee1496fb1092d191d22786963c45ce7c03dd168cf31c1c042916f92acb2d4b5b96566e1aca2d8c25e6ca91e2c8d1fc07cdb4833562cbcfd8b66d7926db90d428f0fa600baa03be59098ca7ff944643ed0d4d93dfbea1d2406e4b301b7b5ec427686e3d103355866885755a871e76d45ae60215f040e8ebb6c968ea81a70231707a183e501e3c2ba7d7cb9de38207f21410f0e20c97268ada4dbf5a6ff75fc7420bd04b72e413b3115063d14b6f7dcb199e85b7a4cc09036b0b5d51d2dbaec09e26a2d4c1feaf387d140182cc3c6031d3bcae7cf1bd98b00564fb4bae6d823f995a4a5005e239d81a655af78d845e070e647bdf31dbbcae027ed872390a8734a053580a8703a9800ff96c6a0a9f8bb3920be946840b42c28a866633f8139fa9d760442fd5a2b4fb6119a6ff2bf1fa8af3872c13fc26c354b3dcf501153bf426eaa1f8d3c06ec8c0d4af6688fc598ef54896a5a4d8218c77788fe6e1dc39b28b3ed5113a790132ddba41dfc604c31130c84fcad56e77d260966466722880489edc77f3ea52ac91cc7fe62692988c9adac107d77c51b44cc1a503d890c7ab226dea0e393905d0775302daeecbda21dd2758d8e13a9038ec0da9077f209fc215b92205d971d61023c493296394e2c53e963607dd61b1cc3c263b5bda95179c4f06364db20080dc9e720121e0b986b8cb4a39b428cdb77649026a3ed9516fc315e09e0dd052779c954d1fdf7446d99ad2230d0dcd3b7cada62281e91bc4728518c5a5b1d2749d77e9f224866e7f7f398c7fd8f5280bd4959c9960af7cae8c11cb8f28ac2e7a3274d923f52ed6e899b30723286fe92aec6b28e3cf57e4be5ffe04f3214be55b2167a26c98bc71b4d7a81b46d7a75c4a4ae32f5a79721edfae89e778bd74738f4a39a5aef3099e1f450f0bccea7a15cf6e889c89660bff201161503450e7f6c79fcee3358bc986e4b07a1df8f686c462ef095728dcea79ebda66073462cb7edd2b32d71e045f797fdbf73bb9acd824561e96a39973b321e2ad8d30c8949cc89e8c4c8c5a2f23bb1ef378455e477747c09ad50a285c9ffe63cab8282bdaaeda2f64f45760d2ad4020d3b395552d2522f24b06717e74298d85c163284f12cf9ebf2cf884af75b00609377f81beaabc223bd034202eda8c5c602170fa0d2db9f3f0e8ec59d6f15a73fe0b111459634619bae837d07d357eae5353e3e580afb7930888afdc6a95b1fa6664046ddd24f33d3529b81274f6ab71835c8fde5180a21f00dcfd1496168d2d6816f4c5a83fb2cde6faa299aee6ff7445c5ce599fbe3ff20632d585b57ff882394aa0dbcecd2b75fe1af514ff0f4f04f63dc2c5249cc3549bce5bbfe8858233b3f4f4a5825bc9839248cf1c05acc3261bfa61fbd33e3070d928359041166903111aaba943616eac4e967ed7d19427fc39f61be623dfd60dc12d0ae955acffe4be893899ce84761a11e0c6c2519b82d22ab0bb8b25781ebbae98bfaf0c29f4e8017175c3b0178829dc5a1043a3d54ae36aed7ed0df983f9c484ff21b7f250d8cd51db18afe65406919af23505ed7dead9304a1979bd15f2d36bbc0fcd7fc5ca0a96e77c09a99f25681b7b51489836eda2da404caad631136b96baf146de34df6f9ad3b450a6bc66e4ab2104fffd860c196373c63c8c8ebc16fb1c34fe529e53befaa67ba089277cacba7c8dd1c852eaa22aa59f32532507d06b209e9ecb0ffe142eb0bcb5c4aabe6675cd047ad0fdd7f76a130313873ab42177ad4cef26d47c9bfe34db86d16aee279522cc45a602af9ffa3f452d6b8a36be43bae3dee503b550d68c85ce81bacee24ed0197da7fa9db05a715980d6df2362a64b1f1914281279d181eca1690ab560d90651c3ffda2095d859a4db0de1863e1ba984c1b480d21c627abfaca3204b82dd3bd07ab995eee171287b63ff37c7af4fe87cfe7ffb68dfe56cffd3ffd03529b0bcf0e5a498ec52f905ecbb7633c94905f723a9126269c7860a99fb5a5c5259d6cec12d0cbe7226989d056c5f63e00c6710701b802a1690790228c15077c06d7526224de7f308fae49b85558e8dc28acadf9d61fe07d7f8198a64927b90b4c4e09686cbab934c81047e0b3cc4862a17f08d2699da663e2c80aa7a09d753830f14fc343399f2ebcbe755026160b14c416833e6ba9e40172ed558692a8a367c3fdd72f81a21fc37186efbbe2a0bde60ba2d4d32d7488abd74c0ccdac65b70e49e701680b8bac676b58b781531352a7faf0d88e0a3d8ff0eae013f7ec247a945bb6d7d802447d30d5f50c220611962a4633d5dafbdfa37e0248c8afe223d6c7245e818a211ae6c0f5f193dd497b3dc714968018f749dd80fb18aaf885961fe2f184775fce7a193079d16a3f38210170cba9c6cafed27f8e8830f72fd791cacb24023cfd35487c1a326a89b3d88d2957d31de3e0430981efb6c0c90db8e51a29780a3d587ed34b30fc683ac78249a8745e24a90f2d1b9d6965b6dba0fcf8c9cc041605a6734a097dbb95a15ddbd93893bbddfe969337084e69f7bd193563a06acb0752b24089aed8c7f08169445cd78a249a428e2b79cc6fb30233b34e0c4c8a21d46d0a3d95dedaac16483f468f4d82a978df78fe64ae303702aa39cf3d0c7ad194a1768c17383c7a6cd0681611d53ee34f73e869a9aa57b8d153d6c868fb682c0e5232839d0097913b53b54dde58ce82e51f86686bf16d8654c81c64901c1c4caddec7ef10d59dc675d08256f5741e9dc8669c6e8282f391456d6e07efb7f6aac8df669475d69c949f7444802914f118135903cb93d805603ee11e191be4c01979ab7442f5bb1474724cecba9aa45ba903f917b8eb3f56a6421d78cf92abdeedc0144bb1c9002d0a3c9d462b014dfe84432c2c6f1f48839db83a1309408eb45a61c6c076a41697662b5fcde5c8da28ef98414755dbe2a63c667d55b15dc9dd06f011fd6cbbcaf9f416007edec02905028b5ae70a152c1d882eed75c2bdd471f69c3a82fe93834969680d9925b8daddbe00423c2c08fde2a3c717ffed4418b79f1b8c01a5b3495062eeb3584013910b08e9568c3d38ba2ca1138f7dc08899431046d3510464ab747c42895a5310fb6b6ca3d1c7c30df1a4ed29e951f4c0ac8e91ae2de0b77838adb7f5d7c0e90ed9ecb5b3e35c7ae766b95d4f400750767e24b494802973d7669d6a18a61bae0bef52e7876b6305e822a487072b7f6708f84dcaf4c646b1a9f42afbc14dbb8dc3c281dc40bed759bacd17b22a6a753167d0e8c52aa1c29cc36e114a186290aa4b28a6d7c880d34c2f7af5bfeab49406bc26fe1427af57937a8d6d43e0003d8c3634c295be2c084c0478afad870243431a59f02219c643477f4191bd3d2c7186e7320ac025b5e9c96629b1bd874edad55828d048b8bee2e85bbcc4afbb9267981d3b235ffd5833607e350ee5526a688c3fbe991e57908e1401d32020a34074682c9d6b811011cf0eb6f5241c669c045a2a5c9eff028619355a2272f0e8270c5f79f61b5dc2783a7069a89af67d09b480f9de36d9e2314a47ebf37f4c043a57437f67a32d1fc956bdfa5f975d588fd9c906b5b411d50e03810122431fe0e4a083d523847dcb1d137b4fbcb971aed9e95222e0d1d54648af548af08b54679422768c469b27ec562c42cfbb61d810926a44c48815b38d574f3c1e5ef6a1878400ec5516a4c695c4316475ecbe14b39540ebdd505ee9b6b7ebcceb32ba15a30a36246d85244e2427c0c743f8a6796008bd582ec74b199c93d3b14632be3a45c5d48408752a58832121fb70018f40a490ca8d1613b582fe4861324f0ed3df196790f5f6e68275c17910a64dd25fad8b3ab13018aa23088c4cbfa4896596ebe0279b4094ecbcd0970b6da6383f835586f9791fad2e58bb93033ec4b23618ece9fd4bce7e7a35e066697480a3df0b1d84c7eff210b3f1ddcd64d276072514efacff763fc302a864583da1a6c08edb4553ab1f56ed2ff2c717734ba011c2d5d5e758e70b19024f1ba41f3e5ec6bc21d18a05722474bde61ee86edcd28d84a2c5091cafd726e17d09b9dfd77942c5438dbc6749e1d510360e94b0eb1d85c6eba82ae76f97c128b4b97f83a1d4c31ab68d069b207e6f88bfc5a81340e615be35223d673c4568b694e579e40953f2151472e3d53c9420c586f0983075d2f6a88d9488f0ef8fb8181189c0ab3e3b537eb1e914cdb1a497da9db848c54cf5a1bcb3bcd7bd51959b7724d80fb913b613652c602b7571e6a7e2b0bafd482c3deb88218d1ae7e0d7e06c4c5e75743f3d71dfc6a23adbad5969fd88879c10eb9aa69c9c2b07140327253e71d289b5efb212dbc6fea4e5ee909d15a2617fe9ba3dc563b19890b78edc10b1a8edf3e8647adbd36e261b93a04f51b5ec46597262436e006711d3684d58e66163d5cf2519a9f535afb3e339a83b88b28f760b38f8e4aa5909604cfe568ae19b81d8a4651a6b6d1e5364f12cf4a2dcf45bf5574368868f073cd2b77cd74f57810dae715a8aae07f58e989f187c7188485e45ecfd76e7308b70bee423f52e5c73c4533efb8062128b38a753e75ca5a1f90bd0500fb2ee7ab5c5a44ff2ab29185a9b18700579d343fb367af6507d7b67f4f9cc51755ae28de0c897ff46c923bdd695ef944a06a6071807d77d36d37ea5d594bc2365c2e5ac6820d21ce682d7a9559b2ac7b4f538fe76fd24abb1338fc3ef02a63440495bbfcfb85b3fdc35634dff8a1294690317c60558d91319eabcdd7a7cf6ddb06793b1eb1ce934b4477972b66d7c7f534a390906cef7eee77f420cdcd49805b6481716326102b7ddc55f3cdb201151097bd23e15796a4d383a9ee10d599ca16b3d2d6354c8f34d114b915dcde3a4f421e77550923df4b7df4533a031413fd6af6a633076ac37372c4361fdd4c637b2c776241027e60f55364de3bcc9e287419233cb0f33afbd0470f55f34e3305ac9e7b87feb1c0af2b80eb838483dd7712d628625b5e61231ca9d3dd960c73e5ff1fd301d9d61c25ae87574c564b56b7748fb2674c52ad4894eb7dd895cf3ae1251fb6856167ad88e6ec87acc9468d108793607bb2747b11d1b5a32ad879d00db2b5eea56ebe947eea78af83665ddf7f26e2f5ad13c808c8bdc175bc43c71de6204136da3c6e3bce36941e228124951730ec482e07da71357ed3469e94337eafae58e5ecf36f245b0a0bcce0e17317f959ae6f800c43fb4061b88a792a82e11a5ccf9713dc219ebdd37fc56ae92fe4923c7ae939c708e0a4f69c231629925cf74157f2f49248a14151954bffe4e796dc09b0f13ea6e469484462a5da2be0ababe0b57fcde3ff964c1603bec75b954f68ef2c55ef0098cf50d189053f452b06a4fc5a8670befac7c5ef5fd3f62e905cca655b1e971a15e62bf5c72eb7aec4da270e7e2173f9e0582dee4f738e94487358c776d8fcccafb4f1c4a96bf63051ffd4fe8226f984a3a035cabff121df25368fc05555a478e4144eb7ab4564a0a00b3d9e4ec29a73530564ca3da32adf03c7191c57f0e02dc85ff69365fdf495a440144de8a348c31940bbc69d950952046261d5712f32603dc9fb4cb56fbbf10d3339d9a9dfc1b6e73a11f3feac59f87854561a1ba0b3760346bd9cb7daa4bb162b1486e8bf0620143ecc7d7c5dc5b7e256ec9aeaae53866f98074c4c4dd70959ff11e2974e3616607e7c7c23570df7dbd580909344d21d508f03f8d1f85cdd25b7520944a04191c5a091ae54895205c1bbceeca2296feb3ed9db03eecf1c91082c20420a1ce69c88bcf152d40697983551aff62de06126006a2a3745c43223ab1cbb7ea7633e838ef790607eb924de125552632b1c8866f632984db5ca1145d291c0c630f83a4e55931156736f3cc192e41c30b1b57ef9073ca624e2d5830731ebf387897b344794d4874015d891e5a3ef96a4f25ab0d252380028c1e021078132ab049089d820fe5c6593b3a76fe933565b0d659253f2727da61b17f65cd997a66d7ed05453bec6f2e640064a70412a0c352074516ac4853ab82c5357689b6ec0172a9013c1f120d0770447bf197b637eac3b1cae0482c3c59bbc43ba7a2f621db9d0fcda81a0ab16bd0985a5280dac1bcdf6bfc0764e48a6c93ca10fab2577ae0b35ca5711b17a62f164f84f6e4a9d974d88c9dbc8deb76d505b62b0bf66fa3373e6e2c7e9c4d7184aa6ae49e9c8c8a0f302ac20cea720d2dd163b089b970debac2bbb03c29c834480420bba680a1718f2e8fa50db5750cb64df52ea565fc2dd345c55d1defb0ff63ad7dd44ab3737a0008a69cc36def0234b395bd018c273bc601d0a810d7425065621ada601afeec564a9ca18c57bc2ab807b471ff0c6be0fc9f33b8d94add1e6411e993cafb8429187374e474c1474fbdccd2e913ee855683891382b3ccc294b9f329d20ac20eaaf84a52f02db4caf5ad6686495cb15819202a0abefca23925c10c040a39a9bd67ebe44dcc2ec5b51962cd046eb14382261c864d2cec0885f4e8a3c64bb1e33a1b0b51ec58f5b8e8410e869d9eccc7e61be378f1299c0066e4ebbbff149166fdf69ce1a9fb8afbfde3334f8fe9a32144eaa3675ca1f63cab6975f5e34122bb4425e760464934d675ebfb29d0a2bcd531ef6cfa029229d2a027f2ce9a01d70ccc732c971f59a1d47baafb9f966aa094f76df053e9024127c33b6aeabd43de043ad2d78d821d372aaf6e8cc80c96c6c9f54245dcde042182ac4477066543e46a19dbfc46bf1eb6f78a95f6d22bafa6f5409654aa037210c183641789e3fa2828ed13bebf860f088259549c078e672e4fd55ba646a23ad0877bbe069d03714174f4e6ab119dc30ea910d2929ca79449335e1f6fd7c57dba981103a86998e0faa22fb869e583498eaf0e3c007f8a753130f4c723abc0d02bba1c6da6050fe4a4bf3c93c296501b912725fb9c33e04facf0cf67b4658e75af6863c49ccecbc645ef5a6799f48774da16db9f56e8fbda74b7884aaeaad9d6f05e410aab00cf2f76983224e64ce4d8f10aa80998b93f1bb3461d348589fd8ddf8c78a5b0a7344bcdb3a9637899514e07fd2648527d14d2387ec9cafa9822879d21e8f0cadd02844d6516b575ca4d251b46879b50746b2a6304b884afdc5a0c5f6fc763e728c60751902521dfacd7359933fc4bc4312639fa6def69aaf80660cac4c72b7c994b2c92db046594ed4c73823af8cfb586e4e7099ec8838af007f3812483524304755a1afc0b410bd3c4248301dc333df9d93ab656d5a3a264a41cdf9267f368ca18c3369b39992e5f195b2ae3d909a596cdbfa140b542f449ab04415b66230f9ac81f8984a9e4e9b092894ebc5274c0d292260469c64529b6a8b0bd8e0c3e056317e9c2ee7bb0656e8f69c6ab87c15276cbca81d8e8923a514459d4b963c73c23c6c87ab6fe5540ca13318067a045aea4a1d255202b1b57501d4e0c2e805b876fbe74ab2a9d20af1fea61f126cfa4050216dc9385c3aceab7c12b6ab78443baac59cbdaaae1a413fc9de89e943942314765456a513174ea11a9f82d006fbd6a19b481775c8ce1900eefc59158cb21c7e0c87658ed2679c53ea0f5c9b8b72fb8b25b5eea0c1d55bc8b7260b69bcb51fb556a28f561bd7eab470b34caef4fe311d3df0f6624c178a0d5d6d307ef82a3edf22014546ff015d5105ea7aac915fee08d43153feb1a958c2e8a86aa7e4072b423d9e3225ea49f1f0e5b6a0e919001e5e7ab97c3fcb962d65d303b74bd3a20199be067fe2454576fc5479a4ef6d310b94a10be008daf1a4237b345d7b4e69718382d2ef4905017fa389997b7f60deea5f7296eff63b87dc4f2403c375c39d389fc50d29ae62040595fc048447adac1fbf3457b9d3570271c57399867fe031f2e229e5e6018cb2a738d960b70962dbeb7f2da5f8a7777c24a69d63cfad0030c5435174be2ca644c8929a9b74462c3be27e1cc2f221caa3824a9bdc7284b84dfe381cc00617ade1e8e7bbd916681703f8010398194712d41a4d3317d5a47b50b894f0f6459d37111def39b9b733251c625666e45b6a42350089b9d85c4e7b7e74ec912fea5435d1b94bb5a83b78ab03d395d8e81fe30c3d17fece265b963e73c274d2ad3a0ceec4e1944261cb5853183729d7e487b3b98181ff8d849434ee895a0f66908d5031a665ca3017d52cf4b0e4ff1a99122d0f0ab9beeae97e9a10ed6e1191506645adffac155138f2de40814bebba51a7367d4c1caace16c180f71752128a8eb915b8bb22ac2af2c2272995c7c66d0093830196313de6ec542e765ac6d3f55116865630b285cdd24640c8ec878ee2af9e64f3c850a149ffdece02e486ff17155a37233b01c594e2948638548494a44a307688b02612f05564685dc86037d724697be8f05abca7c2854a59fc2b78715b22219250e0d94efff8a95a2547f4f300f831b66c23bb3b53008aeb0c5e2e5dabffafc24a755e5f2fa51a8bd2680ea7f94f8b4620b50522d4b88c2e2660bf1293eb2f3e813e91dec8dc9819cc251a9e327a89c30c8c41a9b370c4eacd1ae027190ba1b78fa8ef9ffcc121f4f6dcb438b828ce9f13ac14c791c434739904ad283d769a07a2b428a10c6cb611ba85bb0fc027e17048a111672f151560c1c8c561fa655404372d7515b7ae5c78f8f9da316090314ca6fe57bf83cfe2d92b01486a7cb57ae22899b554480332daccaa0ed5140d1650b506ae8e1fa61764263ad0cec3289b336271a5d25f9a44c9f28e02fa8d37f61a8745847e8646491320add8401b652112f2d59c949e6f7886ea30153f3c9df126833708f06367a6880421911bb38cd2e267bdc130f27c533766123dd84741b2ea7e7e1f5f080d87bd7cc6569dcafd6c5742dd6f13891d19a1e5b730a1b47331c5d41901408d97dbcd0654fdd9b20c10735f1117a065371d20f95fd7876bdca8ea84a4cedb05cb780f9071f02aa771bc324b5673f27b27bc0702367a2ceab41d2612b84de1d7c641339efeaddd1b64063524fdb101661a18f9d77bc41aeabbf32512b4658857208bc49eec1e39f005d80a5afa2a9b78450da43ed09b0bf8fe69023e3ae7fc1df6fae6620d0c0636649712708eea044f3262955b268a2f2a9a2d9adaab9bc470c57c9ab69fa33f681fb916018af991e9fa2f201877ebb26badf7b1a57a8f92f086b9e58b0c1550bce2a1603bda4774bad81ce0a2a23223e0598bdf14611e03f7b7681c3962993d88bbe3dc0e91dd3c0087e2267d44191e4dddb91563e1e67709547891df6240861d06856469c2af66b20dc3a1c3e29255c516658a84eaa9463b4e1a36544b11920afcc29046148750ae9e0203f6360bd3f434bfffff930a50bf659523a748009a54d233f567e7f94995958cd603016bb1ba55644936fd1e310afed99c874ec1c2311bde531eedc6a8c792aa0ca26950d36fd9dc2c70d0ed721784458eae2d061268a38cda1c8b4bebd9159bb22f210f4763e0b5a9c85a6ee5fa726825b94b3bb444d1a01b2307eb72191e1f950770e32f48feb81ea6a990af448e2d53809a3547ed5320b54b909fb908140c0acd6c98c96e938dd78438bfbf9e220ed76fb1457b06339e51836e1134f02a55d79b92fd1d8533348ee7cc2ad266c13731392d0c5ad9db09d13ed5af206e2402935a2973941347727039dd32b070b047644c263496ef41e081075090706ca204689d4384889fd18d103228d37855fb9cf93405669a9098f1a5f317064650c0f20d558b1fa84ac78e20efba55b7251980b02ca382960ccba82f0563f6dbadb153e05f98d1d783f798e57345ac9b5407fe8150fa83d0d1ab42fa1a93578995d8f776eb49fa6838ae7c87902bb8fe095a8d4faaea4cec17f62fb6795de41a5030b266859da5c276fed9d04a6d95d41b133b540c1ab4d7a2283c126d055289a1c80fd40b836fa344af3222651c8a07877467bc197c7cf22d276141d7b214ddced415097a9603e9f14bb4569a0b55db5a53aab60e3816d2e9728e74542c6ea82b209c866d554baa5681d7aadcafe20a01e22a78d94eb1a2e3ffe9309978547a67aacd3a8e22eec3730c3b4b99ebcca133bbe6af84a894a90bd38d0b3390f2702e43ac45302a92d229a762739a86b7030f061b8191ff5f0183d0f73b329f6406514439d4bcccf9a5446dd5700e2b242bdd6261c4bbece5634281b0a0836f0a24907f7a921a15ae31fad234c020dc3e44eb6dea394aa83105710b143623879d017b828b925c27cbe0501e01473a58475a47da9df947c75d1647cd8bf209e1b4beb862b1d94340915d333cbcbded6bede157b9bb43deeec6207ce16c2286a5c96d4c9c4d00e7019885a3744c801f9f61afc35c486363e4832a5b283c28d79d545c20d878f678430f9b8c179b74650ec17a59b9249bb0e569d58678f8c389cc5e440f124ee7afe66910267a4a9696b46c65f8879142c58840258292d26749aa10697c2e12526a4f265a1c70d99b30db69a23d818cfdb617966d182186b798c8431862661110e81fcaf9ac4f277e2e32893d0170a2c5c316ba2415611687f0b985a3bb974ed872fe805c38c10f786193448d39df84b0b6e2cd5a0367c16e8b96bdc984e084d8661286d365be0c3433bd291a63d4d6b2fdb5cb4a0f97c92a70308ffa30ae3a02a110a7ded26fa16dd87bbe4e20059390b139c1d15aa8b7da50cb89ae73f239374ef97cfa69daec8c8a699f1b7a4dfa01fe9c1d03a6277015ea33dec540ed435346b9a6e6b77d69b8480bd633f6523bb775d3308c1ad236cdc182fc1a60a38d5b8491df85388231c3308247cb27b359db387bad25ba972485763b1bf0e67f7671807e4bbe2b8fc19c71b71ab8b475c828c9d7ea0b41bc77f4a5aa13690f69524465b8e96ccdd8cb4c87b764b468f1f29c3cdbd5d667b8831285fc4b568776a80ef324b32807d12f70c9ef7f0fd30015167e58f73b1229bb17b485e91629c366c0bb6227f4f2cda6b4af7612158787a27c5c5c4a13cf306e8a45f749b597e3b2c04bd664ffa18d768144b7271340f5b0893f19527a363b8cb02bb5210a3f164f94a8e92afb6216aa280f603f7054ae3bd60a99fb437a9ebb5455c3b3281e8756a9469944378ae6fa60589a0c627b759de6cb4567357a8b2bf0f45c0ffeeb507f1741b7a794a24f1e37057a74fa200a77be2623139e2bc2df3498e60972461fcaa7b18362a1d9d84475f55a574adf469af0f23682f9b9911dc0996ba340f1d4ce69ccf78ceae0bf7912f727ec0a79c46c89b61e9fb2532869f7a96d7b8f9568d6edde61631e7e952a77999f7c8d44b681eabcafc14f78ac68254c9e3e42a148e0a57eaddc22a19384e986f04d4093e9fc1109768b28a44bad37e70c61ee06adfbd89ace9e626b9ce6fb56852ae43bc73632ea5d960f6f7ab0e69739ec89f7bb1ef952724f57145f3c2d2bc47d8f4e874cf23d7f9671dd5b85d15e78eb96bff553730ae957e1f0aa151ba2030b5919cdab51f60b718757c6bbc927c444905cdc5c8909345686680c3d55411f4c69384d7c705eb9e45895637d58a99d649f286157ca93fd1f79395bf3379c21cca050e03e6de25e014468dc8cbf8d25b13c287cb0e900063af8656780e441953f4afcb977fa7f236f30181b51b7e38976b3ea8b194f78de7ac9279aacebc4b1dbb01d8728d94da2cd15eb16961dbeede55970fcd5e2dd6a528e9c9ee4f0a290cb6739594522c4e692f6f7101657a0538de7ae76bf61e6368cdc169092d91ece163c774f8198c40f1b8ea68e23f814c410cf5a82f748ce86e633e1656e45b591e954008c4a3483646940f993093c99374a3d33253eb305ca593b5098e8d9fa425f40cd01c0f41574e92af133879432c0f6f0d5c73e363b1568e321a9ce44abf8bb7bd7800b8aa755bf445075048ff556abc1acaabf4b1b1a23e99fc7a236be5d82d79dba76d5ca447cedd40615eb23a48664460dcdfe951cdb1c0a9a164b130a41df0a6711c06584c92274b452d1dae883cfc637d554077432cb7e0660baff3f74fc42d54a75c7ad083659b0cb08da04e5700b71306b06bb5fe2439c08f03163e2ca62611a10b95e55fdb2b3e0c6a467b9d4e84cb58096fc44c00e90b21d1cdc4a9bbf5d42426cd20f0f7a02012d40374b4f1eaf287fc789c701959ee32a3086962e2e07756aa8d844321456538236e9b01ad23f5c209053a9439a74648073058fd51c0af34eaee688b683ce70746a44b16ff5bc189e5113b13fd79c35bcd032b357d5ea71271dfca798348f1fa0c72eac95b07832b167dbe79ea4d4dc91714f76c4e783d8e1f3e84871b618ea157cd30d4b29c6bfca1268ed51673c7898bf2b4e994b9ee277fadec902e34b5f29c155307af2cc27bd3c623b275a719ed362de129f4bcfa5aab347a64d71abff530a24fdefa9d466824bed848bda6c1dd6e118e42290e40fa420c29044d9cc42fe3881d3df614e26b4c9ab93cf06a414052ea75b1a3927ff385b3bec5cbd31509ee836a6a347d3646cd954dd4037314ee3596c1cde37656d4e00393593a3c5521dcee29f4d087588bcda84557d1751f834c721bfba2e92a927b49bb73030bd8966b9800a674508b5cbf2c3a4dd527b10c88c68853cabe5c1593fb0cbb5c64cec0df39cf0632f8ede4bff3bca421941c1c6d1c9ac7003d2b0f105a37b89ce4745dcd77c1f98030969e6cbaa321b78cbc71f7d83a83df5df7f6ac003ba824014466c22cd47266868e2622f666bbcf41fe6f61fac7a7531092338aff1fba83662dd81292337504afffa694e20ef63806de922f560cfc1a4e91dedbf6735ba24303e18ee9e12b444a9ad949921ce1ca5c4da60a96bcbfa441b40d72a51ae03adb54ec7843d4c65109420dfe9f48686349130f2d8ae767d71275e50ffc4e01597b9bd39e828736c1e242031454d62905b0949e2262be319e31b5043673267942da270f3531224d3a80c02090b5013d0223ce7d4aac4c45e13b5e315a909ba0ec354e998eb5ea58730cbda47503765588f38ea2ca24cebafbb9e95089918213ac5928e66568c6371d36ae7f38a2ff305dee16321f5db560a5aaa558e686c79bcff5d754f5cc6d8afb888f0307b1aedc32ddb914f581d0e580d12c7ee74a93ea984ff79827fa94d1d32df7b8852565dcb549cd2fd606987496ba200c45c9df7c2c67206f1dbbfefa6226ad1ac5b50cc52758f58b921d09113a4a21225ec72c7ee85adb052bc511724dc22127c4c8468f1bc811b044217ec059478fc9044caca541580aa1717c886ec536505b2643d116abde291a3325ed9224b89cbc3bc042dd2c7690b1a77c5944484b95999d973b1fc39e6437ae607c3729117861a6c5b8484786387bfdd78e16d115ca34004c7bf75db71fde8e336e8e29b28094157e8f001719cfc9334c45b9f696187e0bd9fff67dd9d71efb753365cd8ac6030c27011cff63156b9959e752c3b7de352568d64324e8eeeb6f8fd8ace32d731be63787a324714d363660a7779fe8715e5402402fb6b619900a149e770e0275db47b895ff4692569fce424a443244d84c413963e994b69d3c905b2e8a8186f1493eec0f57c50ffb39a140c2d683397845c219953f42961e6f305a1870b22626de80672e2a88bb4558e8c3e31e0cf24ae9c0dc8fbee458bce7171be8f366049e31a577e1fd76309213a423b492158de1661d253a420a553adce676aa6b1e850a5bce0fca29ada1360ffc92147019b3550fafc0443ad8ac97f4dce10bc9f69e8f3a1f4beda87910b1e748a25c7baf3322deee39e0dd6e14cb30d00063e99c666987fd8267f862b69145eb1db627fa4b66ff986dac78229524b44c4251d6c7a991d0a7df1be3de85e5a058b322922893c66fee00b5ee7c69eaf427267f17a46be101240f7c4410929ce7cb7391ed8468ceb2c4b0e5aa86928feaefaff4db5cfcb0e10eb45b343c1b131ef506824b17f8c75400a4459215eb961eba098576c0c864d3055a1470b4118ccbb5e5a3dd7583959ad0907cd2dd4a8a515eec673bf567cfed42cf36d380ba62fd5a7b22dead7fbd9f7b77a07c3e08c4c5ca38834b858d334b11818fd837d0facc3845496e77566161132a25744d9551976dc15c83c655f06dbd3fc3f9d30854e7b3ac57bce336531556ba1a89d4ec02ab8237fb34ec3e6b6bffe88367f1cc54a042e528f59739949f5723a0b4b7851ba97118f251805ff261904c0a240f791058ed78c71564c415b26c2b7e9a3d141ca9adce27f5c5d1a217386ac29355178cdca95822664a9ff1d5ac9fe7ca2a476dd225a4df6d51abca295310c6855d43b9206aea0a6f22a9a896a0c7cd9ebcac7d58b35cf62b35252b7999310e19f1ab04c913d44383b4d36791643122ddfac589b1d62373e8e2f90abf022b471ce15d4fff5cca1ba2367d9e25abeef05bcbc0f067fba8bbf13926ae08d5868af5fb779b046a95047c1bd27112281beb0b8b9e1b55e1e451d1f7083405a63e43b9c641928e63fc08b6ed6d3744e7a2a4202d67e72ecaf6f00c5985cf5adc2d7ea514d90caf479d605f0ed58529137935a6fdb2b57484f4f788b7ec1c1e409006fcb51cfc4bce9ac97e51200d6d92c25145c391a90cdbf8235efb30b5ba7ddcdd2ddbf1615cc61e579bc7b71ab51657f6efc267b52830f16c627e0d570afb1ee391556dd8a5590002b35f50717cc2d2951a3298379ffd44f206ea1637ca96a5b034b8b5ea9bd0cda9a699d3fd5e30fd36749b755d19c7b5935a60fab1fbe89b1d89858e06852b4f643472edb6b8233ae9ab42e51d66e06f33908333ab4d875fe93c0788f2ee7f8db9adb4fe15d5e1ac5e76b987a06dc36421df49f0df551b7f941f138f4ce437a692faf028e6a577952d5388d43d4336c5e852fb234b911d191671a809c8a2915d2b7a91a8301cff30d1252a111f387c34eaf89af05dcd137dcea3f13fb4d0bee58639add61d6aa27e89f1b9b8e39767d6e0e2b5ea9addfc3a9077cce336ca38fef50b20be8aa50fa66c97266beded10b069d699bf206d33f8280015895436837b716870b72c6912631c48fe3c73ddef22dcd33c1a37947fc46f6357eb33d629e9674c87b34626d7ca0a43fcbdba977809725b4aabdcecb7d73d06e0d64edc2a0029d56305b9d5f7b34e57848288e56e4f14bf01cc70d1a68f9d0e0edec97124431f7e07320b27bc57ab7baa209f1b6fc668ea306404a9f1033f638152c20023c3ce855dba3324fddf7756c1e6fb6bedc77cb2b481acff93d2495538c4e7406d35cebca07838b532b354f179be066f2ac9b0484470eded37ef4adcf7204f6bd028e814094a56a7d2bf4229c5c10449f9532222e2331d2149d5936628a2d3febdf8d23b5ce2a0d609bd32f2666c20cf47f28b3f05d93a49644b5e2cf43847285c1265c82dac5b3acc4eeed0f7e85d3106161e0d09b0ac60edf9ca69b9882a19bde8599b2e959ff2888164c1ecd0eaaa7b7d69568de893c226fa72540be2ddff05552b6f52c39948d8321ed96e881fd71b29a46c1abda683211a71f8cae18e6fb51331d4f7123ce9ee612b4fbe31a0745590a5805f3417489f5ae037b12c49f51f647419d9d8e9f6c746bb03b1f37dc96b0c7fd8ddfba3c312992a178d1169fe522852db6ece185a30ff7338985ee8a16b9f9df26feac33c29283c70f8b61093baf787c7044e397a4898f54b78d7f6e12204840313d54e6096c83253a1b38bd1d279f0b25f2e696e5a6355b24cd8892e47a4e3a3592171addc5c3851335b58fa4ff98d5377f7c266991b6ea9f2dcb43e58775021b337e1521286b4a6da39e00b8cc5a0159f32b58d5ac84911c370c5e3581b86935541337a3f95937b898cae83722a1d724d66ae11913779de770471d0439ac8adfe740db25870a8041ae4b104fd3c72816a93b59a2a2909606498aa8a82d234fb2d7082b4fcef0727f3b3b2ef0d7ce3b8048633cb95235581f7b1ad91697d2fe9e585324b3e312f1bbebb2f5a5f9ecf420e29e9f0bf4a28ee674eaff12377d7a21b3aca7aaf0b8ce3f0d92707778536822a7ff5b65d9087566e83fefaac501aff672340f55f681f54840ddd2ccf2a134696dfb5aa18a1a9da6ac8a0335a474cb30f2ab750fbb0c51f9e7ca4c022a8a7542b6573fba1a3afeb5a91502bef94d1d67e4664a7a3cd1eaf896c0efe35bc1e381d04d021c5ae4e45160e491d4d134f042635a61ecb7680474181d30eee6323c786d5b0042c280697069572a92ae898270c3f55d5833f0744ab7e0578b64f759ebfbd0c303b4ddd70f04eed4bd17898ff61ed3fdcbf0e80ffd3128a7211605eee25bae51419d5a03932b8e7dc36f2631b1a9963585e8448a6e5bd1bce6d3fe9023d978049ca3a3a00d520e81ded6cf0f6e89f1bce7adcc901ac99af9322f301039dac77dcd3b8306f45c29d3d6ed81bd153ba622724852ef558df1e14d1d2a4bdfe443974030d42681cec541e0ef22c6a2a3fa3e0d0c3df8bc6fa68eb885901160f7b16b450b7d656e3e15ce136585e9b048425c9ed46352cbf24e32bfe044c82120b47b452a3e68c4a9f911ee99d4611262f0878396ffc0b823471496267861806ec0d7ca6de934fcd1c850a2d48222a8a56d860c15c2fc361e5e87075c2286935fdfe0cfe644107a0fa977f6bec3f9abbeddd5b27ab6eeb98ae8bfef9be1377660fce3416a7ea3982c3486d22b4ba02ce90989504cf3d19d6ebec75b0d5f33ad8e185d8fd563fe010c8ee5f7be20dd8d27d884da7b26fadd98e5622ae382c35d4b40e1c2898c074bdf31fe2644faa0bc3f14cd5b105a3a5fe13dfd1bd3edbed0885b5c3171782ddb5bdb7390a6bcd4d10818d1193c375601d8ff762dd03c97c64bbf54cf8651cef7876dc64466d98a8caadcaef8dfa9fce6ba3e5d549626c5450c5b4d426a22ad67f4415c490cd559e743c1797458d18523d3853513e58b3c022f3a27b55125c26eff4e79cf5b34002428490468fd06a72b1b638ad20c760e929aea9a9972bf1590cff205067a0cb7a6e711048e42ec7750dc967203a780755416f122d836302787e25e3b0ffe6676e7d981d6bf26837d6617a94e0ba62ccb022353b7caac2a00757e98c0e3035564e1ba48ea958a785f1bfd4c5c99ded3c71de782b3d40ecb13546f1b0f4dcd9ef8b95be769e952aff14034f03a3a4953ad3d1cdfbd3c00e374975309b04252c1878ba032cc74182247521e00f0d1f989497370f2702efa7c78b6c17a9d42adf0d768bc4897b4d6e6ddf54053eab883ae14c3429a117a123132c554eef553dd7f88363c2124ffa12425fe8187a91a1d987a8559de98a9cf517a7d17cf058b80a0ec28452c519c7b517167a4d642134a9d365c508d341d9f0ae3238629f20f7af3d216c669779dca5b62d097b99cd9b0902eb92aee25a1ac422c459ba7f5a55da579a20df5c05248c95a86526fb98d2b2b316e986d7a445312a24d4dec5dc4fb8fbd6e25c445d7121e18face8be5323f6fdd4e5ad06a2a0664e3ed261cb4dc09b3ee965af2c9e0edfbd19b1f399ceeb8632ec2cc8fb354fe030a81c8c6b9ece1fdfdc92203180dc2eab51651a0f9d87e5d3cb062c087abf6c88d47032c7accd51a0d8c0ef420308656d2f878004d6b883893160870a4648c3bcefdb2362b2b8077bb5e1334e5713280e8f4880a1e53f64a2c007c9a5688320e733172b2ea03c0e5d5e4138a1bd704f14e98398a482e60fb5cccf053340532a196e7f47d57533980fb82a31b3e386b7c0c52e7ab2da7297c08588c43d710983794d275b1a0d2a9a9773141ff46b14d72ed90f4792c1a1a58828f0eeeab576f5a6c5d63557bc240e46693d7f5ff87394784fe99c83ebb234db6648ddd728cece279a3934348675c2982634b4012db7e9071c1ee3dafa3de832721578b0514b9405b04db87e79395fe3c63154c0e9fc9ac01275ac9223c87463c63969b4d90620110c9d51ff2dd8ee0408845724c11be7a2478dd8ef505f06e58924752a6e9ca263bf0a4dd8d76c2e0ff8bd49af192ff05cbf4464e1516360ce92d8e4ba7b62c5d705fdbf703f41bc55dcd96d183b2bb3560e883b74297d09aa6fd604aa00876e25236d1f5c5fe29027c48406d85ddc5ba4c9f52cc9eae83e923b34e37d9a7e93432c73f12bd388f99c29d0f6cc1fc6e41dcb4f2bd0e10d49775056aa17a6f5e61617b1e714296b5dfbce6346dd0b5e01deb4d6df82d0a305aa9b4cb0239da02a5b95328dc6542ee155f1fc7ef093b8a0ca5f3a588a46fbd92af4ddd44740daea4fd42c927c8023754aef3f93e172e38db699244b2beadc773f10c678de8688016bafa48e861bc828e6f41decfe140fa9803d10cc60bba3123b4acf3bbbd5682cb830f4316381ee610f9ac18ceb77da0cde867611bd4eafcbcc60a5360d5f4ecfdeb60e806ad19100aadc2b5f150684d1d3af6ca9b1ee994472411164102f7b9f10bf4c9d0c84f996479f86f55fe394a3aafb173fa752e96f0257c19fc0ccecdabdc555c8d8fd69fe6608dbde83dc9f50cda37d86cbe7eb8cda89bdb014ee52a02fe97e2fba50501e85e5173d13622900c7572c0af59c46ace5a720a14366fa27a7070244a31d40fddb5594cf1bf9e57c88018555a7ebafc3c3d3c31714f5324ea3bbac219f36c5f878553fd3ac6b9ed1d1f1942b8f19b8c03fa766dd16f7e3d58797f79aa9a290dd439a1727e4725cb2cc93efbfc719634275cc5fa17a217c81a407c00c8fe8718c9041b1b55f3453e31bf3aef1177d3a199f7a9f3bf5f0516306ccd1eda2cc84505437402fcde837393ac7f7e30d27ccdb37b1a18b46f184ef7fb66cb192a144a3a2eb9750d314dfec16801a4ff4a7e4cc4401399803d23abfa707919d2c7988ee0b4c0194e1e6faecf046f6da20681b0bf34fa2f1e16faec70a11b80225f301a19695101e533d24129c891d558e2d071c46fe3aea46e124424b509d8929a5d15f5600910d929be5c049ccc03a59b2c73b67dcf309d1773f8aab45b2178201b286f310630a5d3d58f2dc5023aad557c18bac2678727a013f489ccfc893594798ead011122115a0620aafdeb6d643cf0219e5e2a6a98ab0c5d8b11ae250f1c7938be9d389576d44bbed47d6e2f6ec842cfde2e740a6a79cddd19c77bfee110f5ccaaf409f39c8fa0ee3b03c232350d2d2d4b3d78cc5ae71a7047ebcc1dd397b40e5caf11643be7050615945869c7544d907cd0143e28b0f4934f9ef8e4ef0aa824d09f61fe941be179d4a6c8470bd606d9270138f7253ecba5f78357cf45cf40b66b4966ae126b0a9b4f9e03816335bbf5fd2e0c5503191f96990d36efc6ba1378e128e7eb764b48be45d377651d628c68dfb57cacb1e6c7980654113e125d6295e33fb8e5458a0614a86280378e6cef6dc3d50a2078311b4f3ba4bab211df377ac83b0042aa1d39987a89ab654913d1f5ea678e43d9507fc489bd82b5695e445693c572dc432da3e8729db9aafd6e5afdf38c7e341020ed86cc501a97b1aeda16a4a4088ffa9f255af3eb08065f6883fdd85e21ad52a4db6052a672d6bd00cc4c56d922ac60ad947e47cf44c01bb5fe1cdca923e28dba8ed307bdd9c2ff2318d218f380da46bc3cb891ae2e8fb7e5c36d3b7142bfaa3b77c017d75e6842c2e65fd9b54d2bd309afde830c33fa40e624d9708f64c4af83d095f1259afab113a130d024866d4284d663632025cf32ac499e712b0a0a11614671bc850f5c25274c3d5be9b8c5c980107667f3edf7ef3c89297abf8d2a5599ea4957f1353d0c6d7b71df0338f277aec9dace5d830bd0b8b8d589835c35ed353408d62a973fb75516e57de5396ca475652bcd648f254ac253856b2310fb61ee8c54b6dd614be07f6d0b1b10e9fae07a94acd00cc1b2b8ba5af799ad4bf5cd3871d344ff67cabc5e8873cb2016421d97e3deb205c04a8424f59a421bb98c26ee17d989b4aaeee0af0639c8f7c1e1307598c1dc3274856d8fbeddf00b9d110dd7deba3ba9c7797ef1ee5c3675a08286df1598433b5e32f43af682ce8862cb2aa6fd75402e301c827f49d47dae0f07d5a18d11e851e77d73b8044440cd6a097362c523d9f4055fd751455307e4b86ddb255e0a8b1f461b234e1a3fd1e0270f9d442fc08274dce1a8911de2e8be79fcfb21b832ac7dbb433fc74fa3b88147d7e3f50ad42ea87942531b41782fbc666c9d35018d78bbfee78d5d3769b5d4236aac98a7e5bd0613e19543644f1b29cd4aeb0efb40688718c9b4488cbb897c6141098a6dfc8ae0022b5e5fa24e9bd211e3c11e451a0586372205f47def85d76b96a1e14d7f833ca81453b5ead5f8a6e3032850b197025b083bdcb8343609723e8144ca924d786d7c26ea69dfc6786cd5b6ee3684030c838ecdd35f3aa75f02b703cd272c8407d49cbb7603a93b7cc92c2d2a9cd11f1ae8d585babcce559837ca4b5ca9a117df95436c874dc4786ee26b492fce901ce58b1e25a524342f772c8d83c8d39a6eabf063ad577ff884ef1c2c887b42f7411870204cbb958a4e215b89d629f16b61305de61c971c10891ee314b54da73b72b30eb7650dd50205258416c915ac75dc03e9fa24aa6141d8efba0891ae7bbdb7ae6e348d00746f9612d5a9bc80dc91c94bf89ce6d20d57ee4ada2a03447002e37352b9b8b2c1fce995cc87b1b5a5ea97cbb371d2cb1e1d9ba87b0ddb7c19e86a1c78286824791d62584ca699e6914d98889f47e711f23d65276105d98cbab1008f0ca48bb16cb585b63c3b8ab5b188483b69d9a5c5a41034ea541245a0dfcafe1b5df1a2c31b74dcc655923402c23134b8eba93fc78af2c9cf3da1ebdabc4e753623caa0c3962c4f0ae477cf638514e7d8c5bbdf463b8f7209e85999944806493946949982afffe5b45da8a52d186608822ac0e1bdfeab2935688b22552785457ef0e39fdb47b9b2c7ca86d7c7ef907b27cefeb72936c5366ccda2d536778c5e1fd8cf3edb37ac587aae92c2ec0d5d894f26996b7eb9c0b30a6062914bbb34d75cc0a6dde0a27075683c7928f4a457343d70825036fbc07017eab212d9ab330759642723841a8369035da816cf0f4c527a5500baa1549a21f338fef5641288be88a9b172556a5724e2dba220d20ea073743251af597addd26e4029d3df3faef46511178361ac67e46bd10d2fd9f1ec107ae03d9d34f238a7ddea18956e9efa2ff76affced9e437be2454db9c45ae87a207f3b701e343a4989086148c56122bb68ff525bb05fbf56aca1cbbc30d8098df8857c986edb90b88f5fc4bc3843e42a04825f2aeb7d4405ae4dd60795850a790dd66af3762d54376616d65282dc391ab9b7b73568283247b7dbef122f9cf75cc0741610071bcee5ccb0c194d77d54bf223bc27dc5e724cd9bdfcc6dde62f9009415a3ecde8c1f7afa6eaea8b927a3a012a0ea6e8f124652661a0028a9e918b34898b9c53be3d339dec029b6ae9937f470bd87fbdaee422fe4c374a9a7ea78b0eb926aa631390569598d3801540fe7e856b0889a64353edac506d77a37ea509d6bdb0568f5b78f906281de197b8b1108933ddec9760b44dbfefbeb49718bd6ee128b1fe1320d6ea762e313d1788d657115edfc3cc46ccc123a898ee3e20d0d8c80b48b1213ecb0885627a2dd2d9e98f97cdb70566e278aacfb73cee128d74cb918f05314221ad8eea73ec961a6ba9c911b68975ee678d0fd4df25fa56cf2ff1a3a8f486d3e6c5c146e23c52e08e94f7c8fdc1a221b8aff9ab2efbc1baed98580aca76eebad76c7b6d004de52bd4846c9d0cbfee50cd6347b804a4e1a136b45a98859fc74c9db76c1097fc43a578deb8f47c736f2067bacd6fff5874d488a91f84a705b8db5b320f4037544ec737e07ed244d37f54fb1645505057750b271a5ca5f8186b21cacb39660d9eae955b4ff73d98600eed88dc19644d2137b4a05e070a2906b1ec6fdf151592f416a85b59c4a5da64060741ebab45304bd2875541ab42bf46e42c70672a71d0ec55ea3015acc9a202a26e2ce91fa5b40365a6cb1c7bae90933b6b9c724eb0c8fb4e360983c06a910422ba56ce9efe69399c3bf821445241ca59abd18b063e74d0f1c57f8d7a6a19726af904e778d78aeb5033cec91b058954728c073682db7ef15e14baaee16bc69a9a137aaf053afe6caf68ffd6a8135bf35d8bdb2b1135dcb2f9c035e0349a76e35811d3ebefb8287c102712dc3930f12cb1b6a65cbc8d3810bed1c5ad1b1410d3762665ad6ccfe0701d51a2f2a04335cdb0e6b64005731643ed7c54b1ae865d7822a6eb00390be6b7ea65ca39213aae3a6c4776d3dc8f71bfca84f79e69f13e2f17e8f474ba77a482a315d8d4e52322e004bb41f57c83366a7f88ff783df162629fcb1794801c56b83b8c8b5b28ef5c9767b4f53b92df0dbd6ab2dfca7302cb504e4c30ddeb29b8fc4c1214774cde3da6dd0ff5ee26f1f5c3090326e177c0b8613d14d0b8cfa88f54f15808c50f28d45107b335fa45fab213867f601d06b920f6f37ee32901b2f4fb5554cd40424717c7574ee25acf95be44a11bd0ba05f9a6786b01955b3c60d3bea263fee75a35322e0350d2798f36cc4f4b34ea5fd731f7c2cd8172552099b452fbcd8c70924fa1ab3fb3d1a9a997fbf6904381495968f40ae17903e3715ba3dbc8be22c9a6050f568c76be2d8e024f29d9e4f097875dbff54416b627f1c2de2f0d29f0056d6d9079c0399663374ba9643847cf8db8f3baa66ac4d012011a6998457932d080ebd66e85d0a0ef3851aeacb89b919f4fb2dd6ea80fb89a0563cb502c656acdcb8c21432382682759b8d167e66126527aeb909af148d4e6cc7e29d86ff3e93b8ac46d3368b6df4f6d5d1398869e5f699d22dedb7abb90ecdb7fe8ee059632f682240eca17147bc84ce430f5f0a4503465c251971db6286510682d9510d1974b4741c5edd1bdcc099e430addb67ae1e51cf381b9f16cff239c50e34b958f0bc05543cbd3a944513ae1d7cb9c718a01335e1183cff5a03076169ee73e01dd9cb388a9f3e974047b2f986752aff1359c8ac0b5459e02ec43a9ec8713e029c032827974f48361eef8afffeaf206fa1eb4d3d53d83e4566c0a926956581e3f06e4aca619cb227fabf02e5a3f26dc3be024f41ce86896cf5bda23ad8ce3790bec1fdd2406bcf670c52db8c756376c77e8b11e0ff6b8213336cdc440697fb8bf7a6504f3b84ef12f6313d25f32af6fe11c0a25b0cf4f01eafd35e45636fb1c73595f8e38c2c2014d20a9056e6d3885fc4173870059c84da1c6b3a35330a8f1535d0d9a6d2e402346db7148028fdab84f22f2de5c072bdf50536a4193f1e3dc0cb6fbdcf392312f94daa445ef862d06bda557d38b5505ee11cc59433cb7ccf45de6aa853d4d6829466026333b97ececb641c61a5085931d020501f52914f8fabe2cc4e370c13d517d74ec3bbb762fb19ffdb75e313daccfe81505524907a1ae56eeb9f8a770fd08542aadda1a9d9a87420e5a5f7f65a00e17e91655a8088498ae12d19553c5d783d8483d07da349313359d45530f31c4e1a7461eb13968d3b8e9a05d9865b8a25cc2a6802bdf67c55641ba2cdbf44dd09fad410e2309c49e0e7d5ea5b59fed20d3d3181c4df0aa9c89b8767d1265a45fd58f61c1a625c6a71f9fcfa72ab9840577fe816832c756d43b6db267a7d781e420695ffcf72c091e05c3d2c9813e501490ee392bfb1be630c6c0809448d523177932a5d1d6839e83b9e65fd50962e67fc2d1b8ceb3847d7fa2ef250b2e24916f9b7366cc103f9699c7f82578339ecfe69e97fbb112511be939e684301f34ebbef26b0b92d59d15e9631851733c2b8d05c7b0f09685bf76fe606fae6ebe3022d43ff25ae21a1f9cf59f1de4a3003a9fb9e135b72468a707c47c5633528d154860c6bd07e7ef4ff6e869b627ce9366e88a5725b93a992810b5bfdbd1583d7227dae193042fde0a1ea4ecc094184afdab3f581829ae97c238c4b593bf8e795289b39bbfc454166d47b7407865f4d6a765d3c6a73dd05c8b2593a53ce6ac806d3d1506d3134739f43e6ed3d6bdc23cdccc659ce5165f98709429386b89ccf229a8e720d245352eae8e7e381f9114ba3f5c2ed28a80f0045b92bc91eedd88173be2516c7d6b1860322e3d4b76c3f7df132147d288f4851d63ca5861bb6fa19c288abbb92699373b569d38602b036a3f34edaf517db21f4fa2d773742bd61e917a8a086ee0929e0623850473100ca41692903ce0f6949270a5459a427f3e75335c0a519deecd667495a627cd256626575b064757581b9079bb2811c5845b0e5772770f1e9b36c51f39f51ab747b13af5daea9db1fb4284584e869c2f4261116f716464d45c1a2ef06dabe29662c6c218871c78d3e00d4069cf73a0993e5455a1cb556c2392ce37282933c440aeff4aaa8676c2bf2daa86c034ec9570b8eeb56d944f6465e13326dcb9480908a285bd4ddff7fa7887ef3257ee4574d16e9382bf4546934ec62b9f0c1d957d98b9d813a7edd3067359bbf2945612a4d8062e75e2d1955e4cc676955f9265e3bb0361ed3d6c4f8b19574cd68684b48ae33bbad11433e71cf64bdf35d1538eb3c7e60ad8dd99864c60a52ba9dd75496f37f78c616a3df671681249d4606cfe8eb86e43c0d375a657931819364327e9ca1ea69d35f8f524ddb569118675ad6cb375817e9c00e924e9e96cfbd3b92a1449763f532ad0385aff2af174dc705265b6e39a323d3c7d711866e600dddbd8cdb6d64e5fba5b557ed0e09a1f63e81b1d71324a61aad83937c451a0f5e703a994d3f92ead2db645287257fe4b1604ae2335fc5ffa99c84fa2a562d5a22964a4ed033fc6b3f263eb5c727c6284b26c3c3f255c21989cbcf96599dd6970a46d6f5c6ac7eeabd5543cadb963f601fdff0d75b57875b846700985260c9b2ffa9247024cf05920707f2303063f604a677e104ff91ef8ea7911009bf6187f9ce4f6aded02010fb7f88eed8335d137d939bbca1ec3de734090000b4818b60ad263360c9529f73436d821db38465704941caefe1640c65de72ae94b211ca0e204c9936745e11802300781a1b139d450501db783316dd381903b2b72cbac0e1ed9e8a422f94498ed0136b6f8a9e2e7b0919ba8d8c41f0093c62af326ead27217ef83cdc43bd50c0caad443c72e26634ff01ee951bb01e826fa02151adf161070637078df9f0098063ef5f10026256a04e2b78d69d57ba41cd6725e58ee8995afdc68854c2444b2b0158e0cd985e7d4e8db91ef508852edf662f0bf7f56114a33a8685b64b7586de8fa289998c4d9259fc8a321da6691c20b95ffe11679c13630fe9fe73b267fd1db981a9ddb4ea34523a925edeb1c90185e103f4600631dd60bada5edca8f175ccc665e680e1ed686941bd8332b46b8cec27466a35dcb8270263584d9fb5c5e66913e0a06d60adef994ac71d5312d0b6726c8af4c6d32f453608f6f8c56bafe90d063d4e8f184a19fdb92e0b8a1affa03f2a6ba210129d40d8b6bbec20757195b4a45ac2c1dd6b8bbd64e759ffdbd1b50a1c3591ed1971e969459809723c089d0bc7348b9479222114471f57e61e2ce9df76213cd6f953afc0e38e92cf26299fbc83a56de90d7b65ba01b5aaddbd7c6a728a424f60f7945d2a5cd329a1ae4fcad7929d93aaf13dc6f43357c696190d608a711d9d04338ced47eb63774091cc2e0901f56fe0853e2a291ad8adcc20a49ce89386859897dc58f9ad261cab6ffd4e63b8dcc1e1eb48b9ff72cb74a3e93864db33dbc4b8159f4099fae8b84338b45fd4e89e43856d0fb82a4f739a6b57d9eaba6585a490b43b1079ea093b58142336955b640db9a97304307276c5760f3a5f3fdb643a13d7c29d9b763f5891029928acabeee81f06404dce1187d99f054f53839c47131604ce54db80c2c69d485b0b08fdf6636bfbd4d3a953aae66e9accb0746ef48f3fb2e0704019d3ed86bf5ce78f80ac138eb5261cf2cfaa60b17e3ce3fb943d9aacc5ecddc140a03c3554233748b014bf02bf2d93c187f8225b26ea8c1f4f35ca1d58f2c0a5acbd2628cf574379c59b0ac94f7c1e478733fd969f7a12927165f6e8d5b763f688b041381c4324f6c59a340f235f2f0943100b58bcaa3d6a3eaadd9e874dc33ed9ba8f9f674bacf3fd7c8707cbead3827491a3fd54f4ab7db813137e4e7c88d4da0915c05c6646c490b935b3f852d9dceb9faee2f1f0dd4ce95dffa5dfd958b42fc49c1cd47b2cabf15923ae0505461659e563f6e9f931fd5a947ea470c1c193ebc94afc3487cf28ae5adfcb70d9371575f684dd5ea715b992d8138a043b595e75a08b5a8c3ad592a10d56a11ed44277e69023f61a8dac9d1e81f53c9488a55307df34c9aaa692b6aceeb3962d977bfe9caaa8fd105456ed6ef7666f09924eb64c8b2620366d0ada6406198fbb32d5dcbf23d9705fc9e04ed5f959a3db9315242233ef0a143dfbebfdb59563688c30e5e9a5e65e295ffa86d54d76475e01ff163b44424169c1ea52e91146cb53bed11297848a5216492b49e7025f6ca6ad9640392f09a5b81f6f963813729851295206ac67da72af3960a81c1cba33991cdd411d616da3b0dfbd04e1aede082e29808ffb5d85b779446200661a1bad7d55290ffdc237e8d7f540ad0bf67ee84daf5a7162066fb4fb36512e6a6e43e6bdee3f2f38ac3cc85b7e69d63f1be0d1774bf4da54b9dfaff5c088f4ce3a44f60ffaef7b354d4cf9d177291c9b7d4a2acdc2950541e6a8feafd66090e3e57d5310e707f8da7e5f1844d5d3725b65636bda931dd81031e6d90ee9ec92854400373ebc4391598793d1d19abf8d3386a7582a355a084ef48ec0d3c8492ea28a27879f327e5f55349527b612a17ab7d29c40230b211c453560cf8f61658482222f04dbd7f98cc2e0a2db1e4899536ec06485bb76f028485403167eb982896c32f642c42529e0594abd1981114a14c298ab0da5f31798fcda5564f32a8469ff210f08c2deb22e26fc097df2cbe899d5c3abf928bf10d9e58e95189a01b87fb3385020fc8f0fa18c4182d882483f17f2995e9eb96c6ff3e770d2ab61f5b78db337c620eace6fdc31675d439ab0c2193140bea9300dc8de3e892578a029d9815f693f027163231f5296942f7bb5a91b87eb2d3e3ca590e02f8534d3b34ba37e5413fc93a0f3dec365839c46eb44c9d28fc7e17aae4c7f9ac4616582c1c2070202df877e181d3ec5dd286d60bd83375f9f53480c59758f1ede7291235b72d6d3af5690ee40b96ede9e02fb5d730ea7e58825b4948a820e1944dc712b6b6e9448b0c8c7305f96c42279d1d82d05d65fb65240989eed24d740e5e773b381902b7c45c9b81ea14d1da0edd8e6323f1a941245bef70d05c78e401b514cec3ef8f2879ba0f03239742a9a00de934120c554ea5c916851b50046f355cca925886be24075bf6a7b71e9049581990db3c1a3cfe8a4409d8531121748f7326998a38b80222b33646ec98437d809b315fdf5673822437055e268f631f985ad809c129916783dc0c815971c79f4d7b345a726b1670457da66d5716123dc2ee1f6a724a05a32b41a83298fb8a97ece8233346fcd24b4a1e37e0451609094548bd9ddfc387da2f33bf3770ca29180e649a90e744a257e0a4e447259ab07d1cc35a0160c859b29e2cb6122d610904b92f0fb9036a5f6fca00cc132df6a37742b03c53f2ae8eebfa328f7f87c52b909381fccfcf0179434e7186292a5087ff48e1a3313fb964cd1becb19f7a25eaa5640d0fbb99b7e455bb6ab7f02316eb9bfd36aa9b37066698cc840ac6646304096a9509f9d46588e5c915d23acf3f23fadc8255b83dcc9e82a464d593ea799483b9300c9e5e20b51c6d9b223114bcd708213abfb9ad67975bda48ff7f123c3f8ea255183e42f548efb75ac111de5cd86a048ad2d470694a4a10c57e8a66f5163d4d30f7fe7538040bcecf149ad25510e4b93ab6414d91848f95252d2a919e968f69a9f4dddbabe32e5ec63aa304b17d5f819f26b121b28c4127ded0d3aa65a4f193d639f7b6bee38d8427d36014ec3b35beb60b553ca2dffcb985df217fd441cd1817f128e57a1ae3ae37d0178c9d8bfd4e7012ebd2447ffe49705ecd2d7a0a55ea9f7099d9a66f37cce8236db44afff932def42a98629c04ea2644e5a1c688536d6bff97f116521bb404ec186dbd070acd3e8852cbb6238a6002dca1ce17f8ccf6a4280b7ea471baf218a3aaf1a7993bbf971179d7d1d4a2290eb5ba58b6f6af1f51226cf7d6782238eee9a003f62bc44a5e34fe2f44b542ae1b7a827cfe2e745a5bc7b6f83d467b8303f7a4c3ec8c849d13e719296b2a791514dfc900b754e5d6c64edf9af014dc843edbf49e9f7037e391258c30bf379dcccac8ec4f23f6198611760bddecca2ece5b1a9e1ceb3ad064bc9aefd7449de50d0d4875e15ff7a98f5ebfd485e97ab19039081fa95990721f5840b6cb1dbfd29cbc232794d191ca079af4685222a042492fb0a9e1766eaee9999ccd6269f473303cf208b215ea061002088688a6a23bacf59c03086b097c53d7f6974224bea309fc1a48ad35e632387139fb3a8df574e4db0b0374e9037f2fb150a45b2b1d9e430b153dd00e8f231d235a4578657cbda154c4a8a63989c1b42d9cd91d6d786d28b34c91b78d960bfcbcb4806ace4dea305409823dd42b86cadfbf2edf95a7220faae3268f9e9af8e4f61520d7cdebc675e9e29e66c61b1d3ebbf37f99ede41e3de749d9574bfbb4dcf5307fcff488c5ce1542adf75b774b684f7489828b8bcffcfe8bf53912558f8e2ba442ff4b5217065fed44fb64a058e5d6c135e3407def2d177cd213801678749c35e9482e3366333339fbcbbaec2acd1791505650cc04c418e830d9a94d3fa080fcea1815a9baf066a75b80210bb8319a385954490cc8d923fcbe61eb6ded9c39c23599d112401f0ac7942c016f6f427b19887acff8be7ee0313d4aaddd33c31f33ea21be62353e57859650d23198b1cbc0fe2dbfdacbe119f8493dc448b06c1c1446fc639dbf933dfa865243e95267a0529911252f8f9fcad000c4c5675fa83e6b6ac4302197d6c54e13d54626e488270a1631b84cdf04e51967ed4aba76644b21189176327d70e34e9de8f4afc226bea354ca0d9e33b97d53d9cbbcb71071017a1d806982eacfe949aa64d8b999d30bde9c8bbe7985cc331cbfe14624bc89ecd091c55085dab2bdffb050853d3ead6c08c0a338d44396b235666ba69c0b30e84acfbc44855507e69a052eef677438de9c4882af5aedacfb08d9091eaca8bb64b0dd88ee327a6a97f29ef18f54b6716b5a237f078d9a6e488aef2a1cef14ebaf25f971c0cdacb1f2c8b942011928f6c4afeef69a8882a05ffa40b44fc4d520b3da4138d32a967f70ac035c64d6fd61d7eaa05ccb04516c524c78f8218599ff96ebd5bbbe7eef8e787db345bdadded9e83271358805f5df5078c1ac2f5e4a29d0150aae9e882910f7b7023f1d9faf9362a8f7233004d21a59effc2405f588708256a23951cec8736dab1584be67c24e74f89669d656dd39e6ffa8412a23a33f9ef23bb37407830325573c079da7d28da6e4df4c86b039b4ee2623a2ccd5135209088601cd32e83a392a060bf9046714ed5544cf150f9a8a02123040900257453762bc13ab185d9a49b0be7369d30169ee1a59a4ad3d128e8e0ed4bace32c0450c07210141fc35da87deeb8e9d36a8425aa08d691674b38d628905dcd44ecb9fdaae65c94fa95096f57b1238fe0f8d53bf6d3c5738521b5c1a2ba9956abefd0ff49f8832c2e7cffee7742519e85441acb769ffaceaff180b2320114f8e8718ccedf008a63adafc0857fee7d0c24769f58b1cfb7d8642149f37868ce840e239ca941136a45d757a4cd102b905d4b4edb8da4b49a8210bf9a22c67a90ce04f4072063563f9e52114c34e38c5603cbcfa80df333e1bd73e2c8281c6183707d2fc8a2f433a0c86f98974bd7f6844fbb312eb51866f8270235917410d4f49e6b0635a1190c2c42f5ff85452510be3f05b83e111d35207427e0532c67de508491bb26e9509c1445b3f238eabbaafec6f21bf88269cf3369f4b17f76b0ebdec96c38b92574259c823ed8101eb0c5828b45e594b4ac042197957fa9866ccf75950bc9e3dbc18bd9b8d6e410625d582f27222e1ec7a55db4170934ad0751fd0888174947b18fb0d4aac39b5bea53cd7140693b4b202f6fcd7dad36fb1f78759a95bca461ea34adaacbda1ef1d1320696f20a28dd2b6430b59d898e7450603f065bf9e93cc6fd3f82d0072b488c896d3d4e01c82b2e584e96824fbfaff5e4cf7872718551b5cd794bf51ffaae8b6dd7bd1ab995d4c40285da658cc725cf425aa2c7db352f4f817576b7dce47a535dc1a93db3099d32113a1dbe37966c6a0256007ecc88374d9e8ad254c465885c1ef73d1a7a7e4be52143709a8e002b89cd6e28872e06a358f63c613c59f8303884d526cea60f9e9d68565ee2f65d1ca70cc60ebe340a1515bf09bfcee30af39722000861bff523da6b5a537d3b0dbc810aab6ec810a6ebad7450e2997f650b614d751c79db25ff3b55198e64d3064176891cb38155c3ac558f6198cf19b30e36b646eb938a7aff0e5e90ee48f41708221223fa379c8b9733d81295e0143e0a71bcf079e9e15c28ab66030c817e40be404a3ac90dff69c3f9677273eb29a936e51ee6ad4c59b9d15814cc2943e635e46d103ec648a7d58c3afd7aeee85be82c4f0f4150d84b84c436ca4b75c5d532956b00859c2c3a5f4be8ff66d4f5e47d52412003655f1a6917f2332a9dded2cf717d6fe76571df68888257eb0e333f18385d14f25ca502dedd5e029c3541234be596d619a2eb4a433d5d959ce60a1a66b1796bd31c69fcd82d9844ba70c7f483b10e96165142fa2784d06978b119869b5036f763f9e0fba3f0105c562d108904936cd2421b9b93f3edea86f98f1ac220310344bee052b6913564406d195cb64c587016241dec561f889874679001e2443e11d10cb14afd88861ae7c356637a5bcaa64a253bb6e24f60501e86855dc7addb4928417d74ea423aeec370b77347b7d784dafd491522bdc578385b8a293fb0aa7bd3837c02e0750db99e71f9d72524d888eb5e01b6e56f3b0ffd3c932d30abcd148efe09f96804e3cfc85582e5f7ab60f1384af4052ef41329a96c64bbd4fab29d9cc686b05edd0b249c10644fb4ed6e397c7998484316db83cc0927900e68321ef8bdd61c5b753a490636cd86f8a0c291c918c1ab02372da9d6b55760e60e2a7777ab7248ecbfa218cd0a4476fbf0c5cf9c5efdee8d4e1cb29edde9979a82acff91389566d960dcce49270929a5a960d479151d731809278a32ce7766896477bd164a6f0bf2b4eb36ef3ee2717e54dd99cae848130a8ee4eca9d119d701f0578def5f1c5c3133e44b8e9c3d72a64f1c3c9d73a474ccc398307587f387f72d51d35c2af3439717ef3ffb2bd7b82994d47ed8f4cb6102a65127298ba8c726637d07fa680a457a8220d11dcbc59c4609c4878219e80662acfe0bfd09dc3105f7fff8418b9a76e42aaa4170c4227ee04b0808eb6260f2f70c197b9549e489a3fc77bf3e5c49b68cefd4357d4204d264cbdd44810b9b36d8853a96732ea8d01888b16cbf26e967cebfbeedf3b829099a3e6890c00dda5674cfb15c3a1ff8d28056686ac952f0b2e319e7c7dd8860bddd4bd9715e435aac09fc47591269e2643ed81a0a46ac2292504df1ce5daa2a3885501b435fe1c9959bc3da6f079617133ae28a4f0b73e570e68e1d56f907f8bfc47362ae0bd4c438fef0f97ea7dd36ffeea37ddd14d8b123f1fd5ad4a97bc3aba32b0d702778102f6cc419bfe51fd074a62c78f7b6a5a272329e606351da96256ba599fc2504eae8dfe0080db7f09524e8f8215ff3c0e5ff0baf2d7a78fb1e6aab0fa6883ec17d3227882192ed388a1ad7216d2ef94a69aba554ae068808a9c747d053e2e96caa2f01931950e63f7f1d3688cdea60c9067749b01e581e2474b6515c3ba1cc9ca31ddfbc780ebfcede68cb8cdcb81478559ae417270b24af365cf192cee33f66efcd11200f47d38f3d3ff30a04a8fe6e642ca99b7d7c40e26d5998d3509c4e3c1928b565f03fe299e1814ab92be7ca046edfbf6379756a65e3b31c950e78a96b2055e63907c046805693cd5e93616936a7b61eee76bcd90a16e36b91ef64b715ab350cda545a1b5f901977d2b73f2cfd681d3a1b36abb4c3be1a3d5dc6f435d9c86ede70c839a0a0dc410320105bb8f4299035d2077e755ac0a80c4687abe099ea76cbe7eb92e261fd4cef7ead5b1d97ae66661d0af464925f45f310dad1d3f03b57407e109e7e43b34e23a186651762eafc2c4139827039597bca7a1dc679861ee8f0ad229d4fea95394daac9fdcfb4c25ab6f20562ec902e0d458b41c07e3fc89da2cb9bfc3df7bab00021856adc99eba3088a590a47166d96fc307d576ca5cd2355290b46b2522089cb7e607894899c7d26d9cfb8a380262eda75a898a824ec9ff3512ace67de4581dfc5f503e1904c3a56ca325ee571e96e65888f9514dd6c40b7eccfe8740b693eb28b6abeb2293081e0400441b9a03438754a9e6b7f1e455ebab197eab31feabc6fd32bb74014d289f5f9640b7b65e6d8fc25f12a48d6755480a1adc5b32dd8488d04a1566ea0f4b56ccc98b95a9c2e2af97945d35327fbbc4aca28bfccd486cdab738de7860b0bbcf42d3f5e4ef1188adfd8b50fa263fbc6c34f443e37b201c698f2d85166ce1ab588dc6a1d321c3b98dbbf6b2eb8e4d78d30f42d74b856b6364ee7c15be64f660b8af897d818903a62108afe4c986bb645efef4f41283d16493a92295dfe8f5ea1b49373d2edae5a24c559066b0d778a19bbd5d6b9117615929524d51c1255a410b65580db98f065d525b600fe9aa8e015584d098066f3dd564c4d4446d4ce29648826e8cf13e6db6c763535f51ddbf2f293be19b20927b7aa3f45493468907c8326ac254ccc7be9d1c4ecff9758bba9850b74e843d9dc96fbd97e1b1aec22a1969a04725c635e8a2fd5f759d8a0bd5cd6eace5d36747f4a10eb27fe8b4738280a1b668f15756267dbf26faf015425dfe1531438f00c765292ed63a4c08d3e41916024a94a4374376b26530e9e3f61cb6712390dd0824a0e3f4e605b66d0734cad55b9c5219421a4b90fcc46d1daa95fc47dbd011792e46c8bea94715cdbaed85e34405d87dc434a4058d523d81456ff422692f4eeaa2fda3ef8c09af2df3457c05958a7aee8a8e519b3e632e5b51de2e080577cad87d9496c88c9e10b6d08229b9daed9eb1f85fa0d5fa7a57d4156a87724521239e968403087895a321323200bf44d8e6ec8c43d0d242813385a6936f2ee74e50fcc2a938d7f4fca10d3101446a39e52426620f30de689b4a2a6acf215fbd0d115015ac99671a098c506337124b4663628bc822d7a6808efe373af98ab1f95d50191f586eee5dd244c54d845d964e8cd766fd8bd4a1710916a187bc2c24306fed413908e584d39c23344265a8d181721ff8029ec5f871a9380e1099ea4237e3936ce516a8dee2f47e09fd647828e2b7d388e7066c9ab41cf7658ffdb0677f16201ec754f01bb7b00e18d841f07a83aa4d30ef9bf9c4eac97d1b792971a5a08752bd0078cb9751cee20a15f6e1e975066402c5aee7e23876592f813146134f23720541c414efb4f9e69f29eb33a37d71964a66a2054f7bc9555b333bbcdb8d5700c6871ac08993a9004416143cfc85240d83c2b662b956c48624e7de8d028826bb9b83ac38cddc6a509e1c93f8d147c6de1b528da997dfa66572e2391995db210bd7f80f8f0259f3391e5fa53d6059f116978e6cd8e1e84efb7d96ced60ab9a286f89bf6978c0b6c9355799966a608be2dd1edc762ed00218c050abcefefdde74bf0a046e72ba9e3756e6d2c89db657593fc1d62eb7a30b18549e97edfa28ab677330c771f7b6bb65fcdd71c60ff7c6aec773d508b4160c40d413cc4cc3b7cde50f94fa94b772d70e1cb409a8e87b045664afe62b7bb0ae8cf8bffe4bc1e09f67947132b2627fe33fc51466735dfcee564d1b12193c0a439cb2e97cc14da95f46290df9937702f2b8296f936ee3ee988beff38057316d1ce33fd827a01ddd1762a3ab6e45508439837144afd7a240ba008bf872cdf38cd7b3e4488d16c533dd9773717b4553ba702642f50b5182764d544e7d148f2e3d4b333861eacf4f2d07ec40195fdd34869ecafa0da5ddc4032c24993c1a7ed0af4b9f16375bdde9ae59743a4be435e4452632df7060cd91b82cfa720a88e8926c5aead7a432ea489eb3bc8e66c236d4f450dc9d2611825be7338be0c2833246564c1c1dd78d2e036a250752045aa09020a98878c56c3e36fd58ea1d9bdca8e2b580fa993ccbbe81d9b3a19419ccead5684985f1c04d99e2b3e63ddc7d3ba11dbacc03da62d506c0143b0f5cc2006e3e7dfc12e353c396225a09cd91512034683905743214778f3bd66f809e34415cb996912f2a14f751c53045ab66eae835cad75250b49ad6e7a02647ab5fb3c062eefd98af8fec8abbfdeab2a53084b0df5680de9ef379ff6acbd1cb158aa32f71968419332fc3d54c35d5ebf6ccc5292782bbf2a6478d26d0d6684df9421982e14d98a5582b02be551e7182660b055e6b11f1e06cb324c15ed98dd34d9ba3eda6178cfb43c5b9e1d457c4aad83ad9bf676f3beb03e23d97f060f16e594c4f5ce4691c36cafa97ec366503e09b61c65a352b54c2ab2c7ce1cd3e3f0078f70d5e0655601d8851c6919789efd4445ec1fe0aeb21fb191e9a8215a48e49caf213cd424d1e6f7d3b8e73e2a79249e4292dbdb2c4e135228691385e0396dca487d1a7880adab6aa2fce03bf84d5ac70674ea7de3fdb39bd1e962290d3bb957b36c7eeb02973eb4223a1d70f6a5e3dab8512a0a3e834eee80c8ea52374b2c0ae065a043ab274d309df898501f5d6473c664a24dac4a487fe6354ce126e06846db2c41624d3552ac01730390b1e272a4dd9ac6ae1b58929bd54720e63a25734a55cf8e1134969550a328426a8fd3214e91dbc37fba274f865d5ec63b70057df5ee3032181a2654e39a7fa2cdefecd6210847cd82e06fa4129fae4439c156eb13c82f7c3f87ad045fa825164b103e2aece937a41eba2d4724268a90ab8d40d431ef21f71d459942ffb98964dd23a71f4900559370767f6f743d8ae9f02f07519fdb9106b0f3946f8c55b989e1fb57206e3230cbfac58065fecab66bb9b1f48093cc471762867cd502256aec25cbe99104b02283af6acfb3b776f40a89d80b59d7f69cf110f2f6368210eb424d185ea59d131807a9c74ef40c69ad878825a9d086eb8d87bc20f88a8a4eca4479c14cf39abe0670a913513ff14cb533ff2bd924523900365f895afdda1001da79a6abcced96a26e1715b36ee9a619f6892a78d69f62d87c572b737acedb09ed39f3ef06ab8ba1b2b872872341866c11694b11118c94cb3aad0c286a1ad0d009a8ad3277082805c0a2b46d940623157513243f3943221e47502914aac6770bfda4be65dbbc51bec1f6c247131f97d47a65ae6304224d93050aec5b21eb4303eb08a1e6bf57d77f73b8d3fe3eec60faf1195106372e580b936ee0324780a841846c769a3af0d4d8374b62d6a1268f549e90a6d7c0303939813f96d90fc596c3dfe5241c369095ed168f73e137576cc79ac1c4419677aa6aaf64c91d6cd29d0d03cd8af9fa81f1162eb9fda4d97672799a8e00e9d024db087b1af13bb420f5ca8bf88418005b438dd343171a45bba92979aac6c1f779d0ec6e02bcfeb7a2c89f7582818ca950e038b86c29bd081cd2170b78d9166c84f6d86b097faae6858f86882a7d3c4bf961993dadf0cf0aa67f1bff656d86593516689d09b8f3dcdb374eae91ffbe55e38d933c9cfdec742e7decebf851ddb0e722f9ed85140847f6e086b6e12f0d909944537c1ac1d89e60b2ce54d2a57ee9d2ba3e350aea9fbad62d6b117a1a1b41b67224133c9113556a244026798fc640e155ab60e323789db769634677643256c098b04450d77c3359e395f0fa60e91a6c92da8800f5648874d7187624f4b5280cfb16247bed6bd8407d6c847e70698092f973ace317163f19ba1b295518dcfed6c18161f188ee22f5eaffc874ec808ecd55c694bc05745a56907e73738033c884bd0f664af48af263f731a5761cffcdc03c427c5fb78c150ee8c37fdf7d5c187c9e58bb45bbb6de22d26f8305a79f09cad9cbd0649b1efce7c146465988f6302868c8ace5c4e038c054b746cc09ef39220b81f881e04d369047ddf9ca97982ddb41c4428edc6f3807003ac7db163cd4fac7edccf2d0e3f5ceddcc6c1e8b96db1bdfb09cab29f62af2b782749c93565d0142baf3c270ab29aa2626a562d03687dab54ebd2abb3a4fb4c8810dfa5f01ba4b9739c8e53a12caf3425af1dcc983e6dc3b68d0f9081a7ee7add4d39b6f5227ab55161ca2e4cb1e6631ab57b084298317133c8a896c084c79bcbdeb3047112bfad24763f9b7a66e3f139e33a0e31c32557c6f9e5499816dbe757b1fbf52cfb1a0ade205ec502348aa778c63e9db3e5e32ee4eb7a1816c9904815b4c27b5300614dde1f7beaaa62ef4485df7f97f37f8aa676b1641a0290af7df3fb3f29ee834f382797a866282a74dd8d298eee00e6c6895de0f227e292820bab6820d2bf1956f3cbb8af6a8bcd5674b0e92475bcf9a4dcf1c8277a7eb09da0ad2c82d5cb2279664e40a906f31bbec22ec614641ef21eb5099f837cc1cecb1688ed3e33ef176080f00a160c9b10d1c5fd20b3882cf626bdf0631788fe185687caeb9f1e0fff938d5737f7507977a23ed49c2d87a9d9d036c48cc6c5364c032abfad9eeba6e9a718d263bc7f99abd754cb2d233cbdce11db8b2e01ce7bebe4f8413571e5501633510213013734bb2b3bbd636a74306b9593c65db616fd4e8b69b0a639ee70de8de448d190585eda4391115b9a9a33735f22d85e362594e39a3d469be3aafee16d780a5e3b9f3df7c4f42b58677de15a83a3d41865dacd280c248368189a16b31a60eeb926b472608e126036aaab658cdc713d97b6ead04aafe87266fbe99f2d7b504ba6abaf0611ff316071ae29a7c695a96a7954e225e7c615c79865a8425c7bd7c8f68834fb24dd477af5d0d8779ac3e98d6d504c0f425cbd8f2f8631078d1ecdf143f393590d5244b67945ed2e11b184fb43130c0b7b45bb36c6bdf9f8cd69fda326a58b4f064139667a9bd8fe7b534d7d12f9d1c748abdcbf71277f8a32a12029745bc2de25fd358ab9f5f010436ff038bd83e3066bb28cad6e52098d976fa69f7a8268e74c6af048196112f827109e556584cdd513c38c9d0c599557c3ebaf6cc3453c84b89caabdc8782477ea75bf3439cf789f880f95a9cbb4aa689ae6c8c6f60806b63b5f562af3b84414dec6ac2a862905701df88d680e2fc181f8b7096a19f4c4a60e65426cdd1f6fa6509873136b1bc7550ea28e813606871fe3e86ae20af2444f487d58b890e694a12ed9540168e5d679f902461006613d681df2c5039a003a3ffff5db3576bd429a005b1c116b498bf60759bad3639a0b5bc4bd1f9d0557c41062251127e44c7b33329132945dacbf9c298272142492e40088f71e97a16ebc06b732593ec63e296b8fbac70ca024a0c5ae8e4189dd8511d5f57dc155064029b86f56614f8643b75f10acffaf7fdbeddeb9d9d499b115c4ce17f94653494d79463466f0d3866d4b31d3244b6ecb5c9592f5bc6a810e3c6597990be4c59e3880fcb5e8437cbcfe5c0e26242f9022e55ff05d9c3bab5cede2a5651da55e16ed9335692a9b843cef9e5f70952e5fdd27d9e9ab49c6ab59471f4e61c8a3328d33bb8827339be95fc5c4a666da35de8604502f052c3da2997e149b1e9b0fb24c33e07d3f8a959f27a7467c449594725fce3476f5e2bd3e10f340d5ecedb361302fb876019b55c6a169b6c4db7f6d80ab87b8218936e0534858b6effb67257664122d110dcd9f343011936231d69af43a66bf38a65cc440d427231a544644e29fd1d0edf745a909a1721ef5a1ff31457a28d43396142990dc290ca624df1e4b819ef89b9459a0ef1c2488a2b427448e6e831b30a223a116347b33cf934b26bca9ebe8381526ce6c2fe0869f2a000e883d1d78e015bc84df7bd325c25979f577509146df8755b8c1c3e8e8d9261bbfa3b12bb1517ed801a6dede446801b2ac3300c3812a6025b8a5696bba3f165d829bb70b5de1230b8f33e2e7a18e9b9f9471452ad4f69bd11047f5257da97e9d67157943890ba0ca901c536a835082db43a2a2d7c4b900d5d68455f2df21f25a9c5341a8ca45b6f787c3d052be0a8b4d981d30f3b13e1bd508d3e07ecfa4abc5e8c5192351222d4ecd2fcc8abddcb9c748f5cf02ddb1e002475eb77e35bdc2964abee03fd57c46782ac5f9075f790fc122f0cb2b1056b2de3c4221cccdd9b5259eb41953541f539c9ec1fcf4f8d35455c7c2585044e564cd3cb6d0005e2b7baf97f865b56d6b1496231d0d61e67ef2ce81b93fa92f653dcd9f281a395fc09b9cc112d1bf49b87f32a16bd6e9fa6095cae135ba5f3a20e25037423c241e9c057c4c2ee0615925ae848d2da728bc21faf5a7f8645c194e2e62669fe4eeaf23c9a619605cd9f32437dea09563d869418e84d92460df74655126092dc4d6d7a251bf6122dfc876034a272fc630f6a17935701f5775f9ef360f4ba03a70a90672f05ef1fca214fb3171a391df849b3f3855e2a00898718244dbd015d59392962db097e6c008a98283c31fa183e2376ceeccca24b998141d3fa159457b663b502249e89d3f09a97b1908a49eddfca6e0fdb77e10403f546d9d0952d0eddf92eeb6177a8aa153e430ba9c093ef26ff458ae8deae0b811d46776245fdd215e32796ea7a6fea7065074aeeff5244e9fccad9a313a328364d9ad0d44882024c68cdc3fcd7994f91ec019b39cbe3a66823e9d201670ba811803d5f1888d5bdf9a4ba5fd88d6dbe2279bdf8d5235d2164f336dbabb11138b9a0c4e68d930606265558a4e90ef6a8f3e45cfb4f187a9a34dc13a36141402369c5beb2499c11b20ee0a6461d1b9ea54d97a35caf59be22f91639630aa7f7d50822c5179a126ca2e5290fda34e761866eb1e55464232dea47b83566c4a20bbd4eecca164ef6f054e605b74e7708a403e6120966f15c6cf088cc999f518af9eed64c410c16ac28f99dc4b2776212de2a626376b331419fa58c7b2f08b9e90af8513c3b4230c75235e599c7c1b3e4412c6843747bbd1d2922f39283a1843f4afe0ac5924c322773ea4bad8eea7b5874072b157c01af96b65205c45d8d5eff8bba217b2f793f1bdc126b74bd140b3284b79b55976d771fd6644bf3747ee41fa10d8c9b6ecbd13276a6be62dfb214db08345990fda9ae9e0b391dc42c2ec7e26388439ac97352d83710be3450f37c75fadce94e9da171921c8fd44576e181725937429fe18ed22d291d08d9cadccbffe9271627adfd125fdbb7343fc6ee99c4235bf420aa479e0113a05a25e40c78a0edadde119e4921e8c1d08df7359bca6e1e853bfe45c0db9966af437e03ff2f14ce66224cd6955c95cab2638c3a03efef91e8bb31956264542301c9cceb67b56072852a98aa2a72023d40f430facc963b27d62852648c56cd0d254bac1e3ae3a8027bd9a12acb567fc20986edae0afe9a4563bf767911c290dbed046f8f4d2e8bf7a22fb338c5f9eae48397edb08ad60c5f78f4bc286cfcdbc6e2409d6ca9263fe8139d599067f0debca4d8ba4120edfb0e6e12cd0744a33a066f2c88b0e5b7dfae2811bb32c4f89cd7b423651adc8b563bca188b05032b056738e79c5935265926211a1357a701db69ae53dc093fa7f81aa4c87a9d967c32fee6ec8666ce46039774b168f22c7ee1e73b0a79beb1114252cf5503483ccce3b0bcd3a8216cb5fc1f8b216c2fb42384b051fcb400c61aae9c7286aa5e2ea1fa835857c8e3b9e7bcbfffd73cbf93a63d756349087177593b79920988b1f75553b08b06505e48ac835cee00e6bf5d6385c9dc6ad0709ac63cd9553cdc80722d86479e291384cd101f874795462905d279b263e82c8953f29170638fe266c3043a48871809df25a1589d4c93d53aba24f1ece3370cb6d15c8b4b77e11b783f1bb962b9fe16b5ea298b662a762a7d9d5dc74e73b5de4d5b9c6ef7653db40d43a9de267dcc028f300419153986f5a2c91e89559fc7b257577a103bed3434d6883960ffa56c18a5f3d25f962d0cd64a0bd50bcde63500cfc42b1cfa7617667f4f8253ec01be014ce73bb138ae8059d3b91f11865d8822c0e85354f9b488dab7d4371d3d816954d66c9750545eddcc7990562233f092e501ee715479187a6ce5fafc7fc620e62cc485332735c38a70b19991e0a920df4d71dda1c92b4826a6ca700fe5b45deef1c044ebb67190ef6ffab431e02938eef7c8b122b60c86e12bded293c8e1f41aa26819f8fa69e49bf8db6da2b1ac5f6ee422ce781010cc0d987d429eacd1ad86ca91e268014e559e3a8545aad26a31acd3b8d93d77659178c7b4af403500078db26869d6e4fedefd14bfbcbb0a03b41354ef8d2326aa66d51a095724b9c56fa1c91a8ea3c556e7bbbe41ad345b4e33f36b818104b4c9064d5fc55ea45c74747aac78e15b19c7f753cf9f2ba27774d615c870b6e2a29c0d59ce5a0d2bf6c4af8dbc800dea87aa0e43aea35f385ab05142acbbfeb9b480718fd5e7c257e06aa6ccd0404c97d9dc2c41b68285dce953900896aabf91c458dc2403156526cb730c6a0d4809d5fc7935b2008f089b64d8d056fca095cf857a5bdd08743c01b6209af4031bd5a9b704a68d35ade21a86b6538b1023eb1191080519606979ee418aa70a7bd4c83a970a73e0279c387b8a5d3ef78c6d90b2197808cc2f313a5ec7a5227c16e29ad28e8085464c11794c1938a8f7a62eef9d5f2fb8f0d651198e3ea6df02bcb670e7cfa8799f2e45d128770f44daa9c857adafb0682b36a2e7bd60e3c4fab80e40dd4c3aabf50e88a6c9be49ef9bd3e8aec9be466b632f8b4226590e636f1e5c8b05eb857777e037387ac337424a7b543d78a79adbc4a410d323a33eaff15b25965b1564e2aa325614f2a8c9f57b849ab435eee0126d1bf2b454192ac7ee086dbad4af250e39d9e88fa6c0a08e830fbf21e870dddb18e53c4cde897441362cbad3efc4ad4cced8e9baa26828fcce1d413adb69ef4afb7143dde928b8b1df2f003ee5809a6c91d69950931b2777d86f2807fe9dee66e65bce2bbf3af235960f4fc555376c6ffcb37c14dbfdfdf1259235c01304581252c669e6586369c22c89bfaea87ff674158da6cb2e52c56e327057509d7b785d96f2f33f437dfdeb0b22ca1e082fe3138a7e80accca3e93e36cfe4131917cefc36873cb2309e67ba3d58bd26e3975cef4588c8f3b7afe268726dbad25a9f706707551aeb5f0d77bd47369fed93c6656648d34089352ac42011dcaee24f5a2d76e7261589125b030c30847d7b9744de456a9d92af63d3bebb01ba605c379ad6fcc80c912017939f20c93aba24910c27303de89a15ef62ab2c1c6b3c8eb347dc9fe62b06cf6142132cc0973eb02bca8b17694780c7430b15921d71f41331f093df39d08630e508212032567f81962533df36bd9205b22c3c2557d853d174683dc4f2b6fc01a0a4ff8033faaf5475501cc3c26818756e0e2fc667fe0b9de0b489479337db33e2387c4e28847bfcca4684355a38fbabe0deeae754c46023ba906093a6db08d64a29d0ccc0143a3036539aa808c6be24814d387151b6a2f0375601b744cdc4865f2fa0be02b3a33a2991a282db4eaea0c6835406fd6591af466d8ae08b2b72f618502f2802f6a6ca2820b7f0ee2b29b371b2c303a303276ac5d76fb5e343f25ce91d06539cd52f079da74ddc0b15c3f3f69950a52a6d02507faba8a518b879f117e319022ff330184ef4139e886b24c27f27ab9ecdbbb6346cb89a65626c48b4d44d7381ac384e6bc8976b515b027853bf854dfec16c502fea1ee32022b374445ab3f456391a02ce3ef80ec21f82ce2d93896c9207693216edf69b9248c09aaf384c4cf9e62d673fbefb8361d085a82bf895241e8211c738cca515a3c1a14342485784fd6a6c11b842121fc19028f889afb4997bb7923b91db12c933c7ddd650d010c30bbed7f2f4e268aa7db5580d3e90b405c498a3cbd608277b8995867ab55d71c459079ae78918167b6003db169aed7215823714c60c4d1080f1c2a1cbda812fcdae4cf1770a853979bc628ac33d515de642f0c21a9e40cc83a7ce92b9af2a800574a9a8d5add5859cfc4fba60d2ccc46d7aa22ec616941f8d422e256d555d2549fdba0c26bf69dbbff6571abf0714b7090904489fa5211ae493b731a3895f061f52c1d2f640619cff1615cbc6d5e3fc4146ebb4e05c0f543f9d9549ac1ebc99554d69086bdc7e832949a455cc07c26412854f37a38d4afa7a6209a11aaae659551539447c46ef87b6b7f1e655af5a0c868d2f261f5a0c69e7e927d018ccc5208d8c9dad26dc2020edba62d6b75c42e2321851dc4e9fe19a00797fe1fc01aa96e1e156eff4102b76cbd4a7821ed33b3eff00c8e3e38928e8497bb070742d8e84519f4fbddad771f9e844c743d749e4c17d1fb37c84f0eb1b8ff985528e5a2319aa859d4bc7c613371739f856041fa3f0463acc871fc3b119f335e149519e71e9d2e0c151f1c2a7ddef7778a4154581f83c58e6a9e79c75616d38439691e5d7f04e3a2f36c56e89e5dad777abb2beb746202f8dbe0f60d0b6ee4ce067cba65773239851e3ebd31cabbeea59ee8ea025b5eb2513c95b4fe722828be9a4084ddef9db9d416fe14b50a773092e406e4e9459fe93486089fb73d5d55066a1bcc650c293b0a2f83aed1d97c399278c4d98a6dbf63d7aecb512ef274d08f62b3058d22c2d3021449ca640ca92c0c2734863c8669054da94483fc6d37f19c19f10be15646bad90f6c3f88f08f026323d10dd4d4fe25c4bc0bab1f1ebd3e1d01ac25452c97b2037f3c5c0b56a79d85ccc823923e456d5804bf50fc373a33d393fad12d1fb6e93f42fa5fede47f93568812b00155332ca7b565ae9e97f1b74082fda464313bccd5a21ae03f11dbb3bff4dee865fe4c3d8e4a5a85239ee3b0e9805b284b81d34170698d15ec191e0ca8db2dec9b0774359bb17299fb32d2a5815ca6ccd45f98127797a225fc47c73ba0804fa165e4a683bb1535bcd11872c68023b3f5e761444626c6e2622c096f9d8605b071b205e2077ec9fc25cda61d8a06fea1e95159233469bce0dbc545b0e494604544e0e32751b3c438044abeeb157353bd584c97064b32e8685df3600567b4b2555793e7a694165faaa53c4f2722c725c26715e8e87b1b0ab795f08fee6afedf3aa2bd08974491e63ceace6013afeae2d7c98b597d0f1dcc187b98be6764e54b58795ac296106a33e4ff06aaa664f6d642b4d9bac92b68b6043c7f6cf8fc1a32fc1147a74183cf5a31a6ce8b3bf08019eb4496cc6fcb9c5254f6b26736025fa85ae0cee405396a98c0dfdc3474c33175e38e4380dfad2f0d1612cff909d3ff1a9d33c65af6565298ff4bc87d19671f5ed5e40bbe2a3337ac7def20b2ccaf8b95146b58d5d17fa0f2645321f6f8b14b38d4fc3c396a26aa992430473a917b03408f8a8a0b288e37535f12b2e4ad9c8d58874a1c4987b7a648f72c8013e707ce6bc4d2d5697aa95352ff3025ffe351ef08e878b7d7e88896361df08dd22b96510b15989e3df3f22ef5f3ab81e110335cf8f0cbadefb282dbff3bfdc5d91dc0f6b844423f89d09c54c98859490bdeccc35ab11d5e2c941dba07b3a523604fe4a002e85a5fbfa229376d0e8c91271b91ba9bce2b4433771b47e0498c058914bd4bb235feb48522f9a31a12de62f9004bba5a18c604e98ad560ae673797cc61744ad8fc5f22db66a6e24fbbc055c087d7b1cb4fefee7dcb99ce7d44b71b0f5d6c31cb96210c31207e2df5ea150a9cd9bb91d6ee3696a829f56a8ae49970d9a34c90bec8737b8561ef06ff2a397e56f24eb48d567f145ee71d07d3fef7b1fe9ebc4d914ad9c1f59dac7a97bb82e21cb30b841b1f307ec1b9bb33f777f6418dcdbd72aa3cc9b08c626d751e1b3694a7d445bf5f20b09426dab837664cb4c7074b6201cf5859aa5d86854baba1c3b1f4a93734cc7466ff552b1ef5f3175a73c6e83fb86ee39c22822f453dd9fd017f9430e6af485bcd45997c0fa1efa62d382939e413246f6be227bc6c17b051e499901c95571bd8ef560039fff6e894daa94ff39a9f3fc22afff9b04500300c9a378921e79ccef448b2915e7662b418da00efe13f2db2c77e810ac99a20211840a270639b12c55f6cb7bc5f8b189430fcccacb13aaa963a59bb7870f94c18b5267619d7ba9d45844086bc61ec6a98fb16fd8906ae7dd87155304a5d9556b4f4d23b729e47e413129ac86451a8fc11d0dba0d496097f30251cf29cc9831fa1149706b1624d574332cd67daca27990e3653c7e731742ae87edeac6c243aae1d2b52789bf4b6f86a2119f78b24c465b3b249502023b6ab868360ae12bdb6680d766b41fbcadf5222f267985091224b5ac57f89f6264c80908653838a8214df579827c906f1ed9f4dd526688c65bae921dc086869788a1ec6c3e7947ab207398b8ffb7200e4ba14d47c2e886603613394f5e1d6c79a3cd175e483545c4d1574f4257edf398beba55aa87d63123b623df9a46cfae8760aada20cffbc1b9d2fea23ccf5a685b878f4d6c6b8e0caa685864c89a0d6e7e5b85063ce0f9e123d6de5868eaa1589bbb2ee3a92c9efb8f11ff625c668e06e6559538c9e7b6e244c558589b927ac5242440eddbf40cd1c59a510a48dd04aa0b5cdae270793e37d89eab7d374b13d29a4ec5d422a422aa995d194b00181a61271e2226f53e6ba7c13f70138169ee755598d1cf63bffa7625dd0770cb7cf30cc3c13e451d32466f8864d7e79a249a446d6b8d6e2eaf0deb6b27e84d5d23dafa36582b4f6d466ec7472a9643eaafe334657c9f7acfdf24df78c90ec7d82121db983afa511766a975b7b91992f1cb320c85002c9926b9ab4c3cefd46b6c469a4c70cab6a2b87db87194876a9d3b2e892df4bd9569ecb63d07fc7d8273fe12c69149653dff8a4e88b7e896e10ff5167aa9cc3c957e3fd68087c657f3b8802870913c73a69403ea4d0481699afba90138045e89bf32b2be734172141c71a3eec252542df849f4bf81da53b29ba6b4ae858990a8ff692991d232df473525dd2854cb8f8c285d5baefd6076f3942156d5bee43beff52afcdb72c7220fec50e9723c59e3d836535e762ec29b2caee341e9c28ebfdcdf2dd0ed98f46eccdd8569d5db5f9903a8e41f3228d250d1549cc842207c9e9292be3c456ca0ac402a0e4b8f457b67e2b576b10501b2d835efd3af0ce0538d7c27e3b8e3cd3a9428bcb139262d2aa515a8e528d7a23048d66570f39dc3dd496b8b5ca8ae65170f6cffb9981adefcbb0ab4886a443001cbf9f23993a1108dacfd87c70c71441ead5770ea77ea257b18a08f88ed312813cf390888e627bcb7f77c7d0a26da420b2e952aaffde15fe44a58aef5ff0751d60149f0e0c7ca134eb804d7b8505308c5d91da2f81364eaa13ef602d2f9ddbb65a70ccb1c173ada5cc10066af83911f455b39ab1392d70fa812d133c7019f312669c187d39630d4722b447d1910e561f9d441b7b46e9881899250071f3b5c9444b0aa5095527495dc427001d52b882c73f9b133a651e85359ee8c3272586d7821743867e25d730c2ba6e20b3ebe5fb23a08cd8e3796985e373ade11f42bf57b6b19e6f16888ea6e0f7c660858a956c0fe0f5a9653baa5929a2a20ce7761ca7078b29416a63e958729c8895122fcce8d225a323af6ddaa896fc726e1ceb098d668ca9642869c436fde286d9ea1a3a3f3e6879c2189db929afc32bba19705c358f85606c90ebd13af9aa5c852bb16429366793b3e2a3b9289a2b39b218880da1e4f1057139a57879ef065224b700875d867783fe26beca13b8f89b3f386eebe9d15953fe3e5c1e78a3ffa629d5e96731eab368b6b8f8dfc8c23ad995d70ada1ff5af3df45cd3313ee607c5ce5f2222a9961250b6cffdbe894922cd26e8fcf4606e12212029cf447e15f3a146281bc5bf16d26ca520c784931fcb420d65b452a40f7e5ede9327f328857af280c85b1d2c981833a0287bcf22e65d0836dd0d05fd6ea4c42c5f188ce05868681a8461b9750cea9b702ccd10911fb07629723801c34a8c870b963f3383772de081ad1b7edf81ab55f38c1657367ea19fac4a7c33cf5a141f558d00bf4beb01fdc4a3e29764181adec25f9fcc195cb3640519a3ee6ee6967a5663148eda36d8112291b38f4f4a374e60af6cf269adb3a0f7fed2c1c2a755ce0d4ab791f0d7213da58e528fccb78073c89228b2e9c7d530d5bd43cbd00963e06c66bb08bfdb9415416195ccdbc3ce8ccd5b1fc1f992145be3560a2e1e9423d340a9512ae60149b06f15799e340db099b62249bc24a465faef5ed13a564e28747b191c860380537d2c879c0b00f39719b0408c60513e88b8c416fd6abefb6e4e19b065f87040d2cb61e3e17f400adcb7847fe2f86df60a15a221b8b72db7ddd157d78985f0e425f303f2be963f903180d574a6b76c734f7218395802134018d266748dfdb4eb4b85d2b94373eaf10c65f1d8e71d64ef6b904d4db4256a358998d60746eee423545b38412d97ab425554c74efbf9a8e0972ad77a8a20c6179eb30929cdb5e01867d3fbc153e644f55158df4310b126266c2b990c4072560636b2a4a52580779cc4cee359e415dbcf1bd0fe4c1eebfd2853c8fdf59a36f9755142ca48728f8d17b5f955919e1a31a0c9fd8c323d50f5919a3f42b86b40c051a081e70268e5a40aff7fe58b86a74b51439985d22d558c87ac99fc117e0bea737da11a1130c619e87330baab793de6c9f2c8290d009f4726419b08a496aea747850b5044921d4c2610d9097e401c9ec908988af993c18f06105c20f901b4c2b48bc3301ff9ace6e661e6449be86dabbe5800df0382484835c4104433306866b9c5d26f06112143375548fd6053286f1fe1df5892ed10ef24f3102be56be76754511b35b0c3ac6434add85d262ef481ab3cfaa33b0434524f9631b5f2e19eb59c083931d681a989150e81489d7eaf73e5571de8a0d37929bb5a9b9945fb8ef2c8fe957d4cb88fb2fab0bb7e4bbcf632b2326a12179f85dea646e97012e3074a060ced4d13b3999d80ce2e129d6686d0e6ed47030f09e1854188b2a9b54eada376e655b47c803901ffbce92bdb50e002227ed146f9d4c91bff56e865b39334f283963060d0c1fb149416bfd0c2607df75adb106855aa6e90bd7ebb9614b4e5f4a5cc1cb681b1615b0b650e3fe3849f61691e02f728251612a70d38164be96185d0b372fec7ddcd4a436425d414455ff5e2ce3f71bcacbcd094c17ca88040be272eca3eeff5a9c493221318ba62fbf3cc5b6a4e5e1d64b61a6b1d8b37677520cc5f8f9a355ddfb28737926b5982f9e36f17dcdbcd555ff88c387e086f12d1a5db3cf5565ccae919762d70edd9b620fedf892808d74ddcbbe3171e740fb25f5691c2fb3282a2c37972f8b453557872e6a64401eb5fca26398a00c72cc0ecc765392191a0153587d4a2aceeace246ff0b080201af7d4f0fe0cab11455e5cf6105a3b6c24901a23e3eccda200809a296af96bad37d6f78c0276072513347c1dfb9e4188d1d4932c9911bfea46e02eb36995543e2539ee33c2b179f6671f413d408136e43b06f29332073775cfd8e249bb720a4cd400ecf5d8fad6d3fe9c0a658d7f740f808c7a72145a4c85331b8202cdc733a8de59333505bd95a887435beffd3ad2234ba00bf557df7eae630a789e3f01d5b8479aec56df9eeaaf99e4bba5d70788a808a70edc2b79d20756dc8a03fdf58e9f9acb0162988366e881e534f154ddebc5dafd0e3c1426ce1e1fe476a182a8ecf61b29262c0f8228922ef32764755d6acf50560a96c8d3a387a80f1ec67cc00ef5766fb176c8ceadf44e2a55e80abc74c552786ea526c581f84a38792891b2989b54e16e408e3798cbea8fbe04cce665a3075d73bd4f8c178a0781e641f9442e475f09fa96282cd83ead44e512af6145c1b1bc46a3694e5bd0712a3b99976936147c087e1add9ce110cff64b54475df325252d90286a51816062a172ba225272bf6230701f03d52fb2df285a20e575906c5b7351960e6d71c0a334063dc2cf89193c036e81e07e049cf31c62a1c9ae4a6137bbf502fffd117ae7f23d33ef482ee6d118de9c5a31847dd065df19b7d51355c25f37ca0c80df0887ac1b3c39495d69d49603ff6afff8f9eb4beae0c64f1ad8759563305ccc7fee70e3a743e357f5d56c05af58085cebebedd6a28a78445caa5ebd16f4c0cef436696ca83dcb18f94f2fdb81311b0a679c067f4084ddfcd25f8ba0c4d723a4dea03d0a6eb1390dbed3d31c801953c07bce1dfa27d1b94bd63d72d2d2398eac13eacc3d23c2dbb3d6584a205ab35a903a103dee5146fc426475c73d35182da60e8c5ee45413c3120a6c7530fe92339c67d401db74cefac87542183c6adf64726d45cc6d78a7be2921420718695113eaaaba6635e1067a20f18ddf3d8fe3ba2285f2d5dff74fc68a741cd5397c99a3965efe1549cd1c837a27a5d8e74fd53b0776ae36ffb40027cf4b4658109c07118893cc42edbe3e7e77991d48d74b5770966302dee0543dbc616e10c65e2c5d187441455755559bfc643586df78e0999092ba7934fc5153455a209b8b0b301779ac730d4a69a1f89b43ad6b876752f2bdc547f891c109a92440b2860af1ee38be8a3280f4f9995c995dfec5526eea5ffaa6c9d60c4a46fa84e80f32379130f5283b63a55a7500b10611d5a4847bccefc56cb2e62024e78322a2fa1cb15069d4a25c05bef9beb657862dd5355436412a89290419e266fe87f1c668977f9fc5dfe73cde11cf06f26b3ebab536e65e0898e80fc92d823c781a43f459da2840a6e4e0285a0b9ddd948b3027ff2c814306dfea679a72fa3ddc6321c46b60bd98c4a045c652806c138b243662a1f9a9769039931e90f60eddc4d59a3baf5e288bc7d243b7ee03b98faddcfe5e51fbbb92221395d967110a54f6e0805ee28fbce9a9883c95cb0f6f4b194fb0e0528ec9a9dfa46e9d97e28c7d7c32a4def45969557009730fdffb0f1f8cb99bbd48de09c82453602cd9ff6a8836a833476252b61a41762af8fd4a638ea3144d3ab8e9cfb965aebf0b9394258270f3791dabac95c99a9d922b12eaf6a90527a557520f9a01fcee8e1ab330f8ec0f3b647a1fe8f7de5502bf2012d74793d67754750b520903aa1b16760f6438f8006dd4c56b9b3b6747c77595fd63a686b28da9d40e41848e457a1bf724e4d7f7fb998596b0b15d1afa80083deaff3f851894e3f1ff941dce3420beac3a0c61923c27966755972446da368854a83d517aa8157c35ea825f4850615e334e4d1bae29f90b184e1c2879d86d113a12aeed6b068811e6b8c36d6597e60390be8ebf2faa1c3d5eae3e4cc64344813ce4c14842d2887a20dda4e71ec612b6589a2f5d4f8ec65ab8f7c543a3bec0b12642815f9331a5d357d9e7fcfa0ef6edc0169ebeaa02d88dd285cc748313bfcaef2db7e502e65d0db5e5d7300b8914ffb723875c43972ad88f5f75a7bbc8ebac2b6c70081bbd4d6551761375b543c33c4fc9c9f5ed56ee3b708d14d8e0802e778dfbf740a50dbb9cc676112cfd707e5ced2cad89e5ab4a0ab8186267ef935fae3940c4c3b15dd57b586f548901899363c1481f4d0e86ecfd5030ee8f779a7899010b9a8580a986c25b8918ca1cb8d2d62334ef700af8b67d32b71b452ccb30ca4142bec1220a7e96c8eecb47af1723ccc0e29ebeae12b465e521640dd396ad30fb48e31ebc32a85350507a0ce162583a8f13a095c84c414578e835391d408e62c0dd1a8803aeac60bafa00cfaa1a97ebfcea3ac850d9fea9746c3b1faa162cd6809752ed613d20d224a35745ed3bbb059eb279144dc8ac79b64f02cf92a549741948cadf54ba35d11ad4ac6ea28520f87d32e0686450e84827c808b6ce10f9d5f32f05e68c81df027c5b3adee04c9869d8c80449619d7afea978100a891b639d28844e551716e3bb38ea7c6935a0d5ae513f4ba0ab412ac042e97ef17c63b8d87802be33e0e8de1573fe2fd68f7b4fa1f853c1923c8d9082ee91a613ba9825c3cd8d19e22f272527a16da2c99e463f805b95cdb085a42858da2260699f5af23553ec9e208563c63278d9e659705576d64df0a7d84804cf59f06e741cb49c19259019e272d0fdfa5d71d1a1e2413528cc638c37bda8970cb09c56c40d5b2400863c72609132aa63682f6251bc7168dff3dc31acd44f7b283babcd6d36be2eb76c1c7425b6722f3edf69c36ba2edb98fe441078a7d548f4d918eb080e0609f3ffe3c8bd2319e6ce6319a1fabb849eab90346fa1520e0ffde48fcd18f93d7fbabe76a44797e0cba35b7e4c22d917e4b0c1acc51063b1686d4302f00be997d3ef3a1344e65136df1511542c3cc4b4cc002d31c2593bab96e22ccc29193a10d9cbb38a6ac8f647ce49a749863ae291085dadff848589db706938e6e21fe0387a21b7ae9a8956a2a8828427a053d4a29092721a4520fad4fc5461640da6f83221aec323cfb5958787456d77f1214cb16934e996c500b9bed3074539b4b07d8b4929b844b61a19d9cb3fe5ff3047577f57f824dcb279b0c2affec5a3a81cfca3c4af14b504998d764c211e3819061a49e9836730397e8726057bd8706239ec92c7721ad31398824bad0ccffc8d36cdac36d0c8239b1aed8fd139e3d2d1f63779fb2ebaadb3bcfa7681efb8c174b183ab1b7e84591bb487e700ea738a21033256a04866797f3c4bdb66620460a7e0d35fbfa3969e111cdda43b1047996ddd23227987d1d2cfe3fb386ad25cf5f9ca4679253ffe05c8125f92274500dfae34ded57510f673a6559603dfdba5d9106697e8a221453b2c3f3fcb70d25321d38e5201b67bd4304400f4135c1c187344082a513a8f5f4e8b72041442b968721bcbbcf43dcb1a0b2d6f4e3474cfeab97a41446ba9b7c82bac8298281b545307b582af433f5bac1d1df95b76bfbc6559a9fd0441b3e72f4f59026fdc849c788926b5cca6e1f6e728e434f6fdb658f0b12ea8da2ed4b678ad0c8710ce967f5088432f194ed70d23716cf162e74d299c53c603561ce610c75a9cb66b528271daae84dd18db14d62241f0ea474ab29c8440d27973b9772e81752a69969ef0ee0967c26e69816eef7b69bd63acdfc7d03f6cddc48a36afb7aad457350a40ee8ab6b51e62e036cce69daf815e265deb10377f1e4b66f22b2fe8c4d2b2f18adb77972461539ca165e0f5638e5c39335ad6d152c43e15419319fa0bfea882839cd39a8f8fdca6ad7c83cf018732845a832e822ed3daf8828ae3ea2cc48b0f07ea2df2c7def4749abb8c54e761873245ae39cd69906770e27ceb82f458ffd7b1aa3f5bf900b4cea86e655fd1dd6d5533dc33012eea834fd91fe372831a6fd573cd4eeaf689a54b4b4fe27f5269238707b44ff1d0754a03df9d12637fc58e953290dfea75fac0ff6c96d9a9b090e01bc9b5d7d260c2f1a87bae6d16528d0fb83767310331fd2b054d1cd8da42d9d78d125556cd8bacdb7c19cf575e8a99c1f75e960205f928f6b630a89012c19eb812c8cb6645f89bb34e5700c60544b25990a3de985123a5821c54677ace131db506959a14812bf59fc5066fd211d42768729cc2b732fabfd5ebe90a877144d1d8541049e4fc68a4ef7a45c4c1876e422d34059b909342fe481763d60156d88617ff6f8957a1e938146be3d754bc600664a481e96658d5124d1b4172469dab9392563e0c8828b13418e0d8bb4197e8b16e8585877f5b3f2d4d6ab9e781d35d2da0a0ef24ea2682c1db89c603c256148771cfe6d0123f53460698b9fbc9bb2f1e999e57b9da34015c34c8f2a44789e4ff9fa4a171bd18723ef8ef1f069efca0c595de227dd5f605eabae27f42aef514f86b6a486af284c424a0b234f54a74affa8911233546b58fb0d0b73a86fd39f6bec4b48a2ad8ed524f126d795719fa71cc400a9b470d18d5e250fceb9f9416a35aa817d715e63ff1e46473a6d63899192e2f1cb5b7d441a1d2efebfbfdd34044a9bb2d78caa39729a3928d3af79d77ced13f22767cee426db3e84db706b3e248a96a0eaa0a8f65d15282d7466c14382a2fb52e1e718c57ae03174d2ceb30c1e4211fa23e83c603e8553a2314d4919e41a20e5552279126d0391c6ff20aa655985930459d8315116458325278a1703cf8c68ffaaeee502b870b7999a241e006b8f72d0d821f557d7d01642e112d6f8f4e5cd021a30d025308c6ca1c4f4e852298fee1d6fe51889f486e22d42ee9157ee1db91228cff325ba12f88b501ce8338dc289cb32e0fdda4e65ecf9887495db40f4b8335e5081c9a1124900a35efc141c8882f970190f1f0b5d77efd9ae6a652403c3feac4521af826929a39fea6b201e901d235d50d5c2e110e30ea52a9d4125deac0e100db7a153ec6bedcf4330f0dc77283b5f5355e8864a1bb75a206320e5811b3b7a6ff34eabe0b837511c865ff9e25850feeeb87e9257befab62e6ce565ce72af843eb11758166e6e7e3f46f03dccc2e5c680edb5460361f1d7a256305c72431d8e80d7e5fcf0dcb460a9dd58384052385b03acd818a8d8a21d2f2987f810ec984dcb4f716b85a5e0d8e642fe727a09b369ced0fabc5dadb813899980191fb8ee54b37c40d2667497ec5b6d4ba866c0145539199855e52aeed1a090107918f7461956b9a1c6d2c5019b46de116a8c2b2cbdc324c1be1e66b921499645dbb20e6e58484fa67b43b8845ad59e2cc1e6ca8f8b8dc24eb00e8239dd405040dcc29561777295a3fbdb7e6fdab36eb1c755a8994dda61066e895b236b6709222bc2a7e3e7417d09138133f126e733a41f149f3739e0cc340622b390e2ae5cc5b8a367370e492d06670a92ae44938203e2e610f413c75c4be0096a3de44da8829212858091adab6fee0b2af11d8d12a3efaa40da7e9981d33cb283df9476d55a74459f1a9defe00fdaf1e098cf55c40fd7405ba98c901e7d56ca8cc09ef591c7b3a25e44ebc3598fac0592051db8544a5f30d4db72c39002b7d9ef61900132a5b36b0fb4b51648c4d4ec129303979160ae30aff5c715143360ed2e792626af893240e8006749046a4f603a449f22516253cd71d999f51ab1d05d74c1d54793a7952a98cb3a9e47b0d4186ddf890a8440c30f14c93239207794e17bc77c2e15c12efa4cb09ff4d31abde0bd626b7419b5f8e2684976e063e6c832106dbc147fab9f98100201271cb386877f214637527f2c852c425aeaad12ea452e46b1f594a17b65dbb93c5bf58fe225ab70535fe4bb30427eb581443b6c6ed1b731a290cdeea85d04da5b8ec9b68c1846b94e6e14d27758a3ffbcab5f3284920464c88d05e934cb7c3e90992838705b69092409831822ca38a75fb9961afb7e95ce96bc54bc4a3d7c9071fe748532be9d047a8c6eab2bf052aa22951f06ca571f162dc0bdc1129d05d0f59263d20cdc934c7c0708220c233a084669da5f3a84f7c473e7ec5e690a12d6845911dc52dc92d3f7db4727eb55d3bd0fd40be6f32df641be7ad049bd1fd183aaa7bace5add7e66879070d3c726c75fd8c65d5fd7af2ba99873392375953410c13629058d37aaa538a0950e8628f80fcbf9f2cff7b96fb6b1f6773cd1a9abbeb5aeb784fa81ca9852edde317aa1d1e00ce3c169f92187d283cb4218bbb5fafbee2fe94f469a79b08be5306a6f57d7de5d80d6d2a533f7e8b68a658540d0ca553a68185b1b3a05cfdd74f9bbda93a4ea6d2862a31e9c428b214330cb56c59e15b3f6ac4ecb2fd3dcb7472e5986d7c472abb917e37611fb420f59c5a115eb741ec4d95801768991f4ce19ebf6644445a12e8c792d50adde428cba915075eb67e338db5c77f518f684b03d9abb58c061eb630af067f089d79abfb3f2a3acfb6cfc0ef41b864229c97fbaadf2059af3979152a371c0cc1b792371b4e75e806582ea9dc0aeae44a5ddf47389413c917fae1b96acddb6c5fcfd87d890ef22dde38fe1e7bd4157dc945c3366168551281e50b193623d4753d7454c1184b701e8af59f9481702b0c6deeb22061038d35a50d849e87da768ea7fe1dc5bc0f6735f6d94b3ed6b1eb759d2ab3cb616e670d9d3e0d233103ae577b30d29e84ccf169ba2b5713135c2c42bd4fb9dd5e5e161e736c540383492a5b91af9b8bb7ac6ed23f0651fc5078105e8fea1b5abfd506e5a077838a51749b1933beff986f9d9331a22c54600db278d993386188996b17b3db5f88181fc9a03044e9b539d8068684b2854b970e5fdff7816f4fa1863df1b281f4635ad7f072ad157c4e038fc303f2f9e1e0f5a9d78c46b7fe82076e18732a57a2465290be6a00062383c3a06044683b4e750402433b67af3ed069c5e0487525a08376055bf486a97215c1b81f1b01ad47ae9ddbe5091d00841971d003e1001df7d8162d7f1842b3779f20af69210005509a61fb39344adea87b370cde02f8170d1bdcbb6c871687668c35fd976b7ed6d4f533ad23fa034514fd9061f269b5b295b511a2ad3d454ea4c543fabc567d8e7ea8c4578a815805208368e9cc4ad5e920aed15779c65862a992db8592819aa15dad8ff0fead7e47bc54731c7fa184ac0427eedb5c5c6ed6582ce2b3aa0c822652c0b8fa4b1c67815b18f8e0392b6fd9b68f8dc4965efa54f5f4e2bbffbfab4cae651f45a9a98dbc0660c3bb040957a05c0178f6fd3d073c48ca4292cf3c5f88e7116a0684aa6979308cbd9cb50f8beb834286926cb9a6dbf9f9bba8d1e48d9b1f7a63f344e601d8e7b8d86f2b2ae7ca9add946afcdb737578d46401c75228f903fe74c41dfc65008c0606c0deb66434e1f2d3c0e23f0d8564e9256e383815f7052680403d2101ceb48b1e131635c5ab6f968da3a242387b23c659e1645a76b398fb0ccf9948bc19b585ca86daa17ca6a2aabc6e815cb17bf6192541c8804c4fdaa7332a418bb9f370f49566ecdd9ef1ec548d52644287c44bfc0f88910295432a81d07c64ae7813a4ea1d473e99e9f07b6975503a1415f1dc878b2eb96eb74bb0d7d6b3c8545eb15a63c7a5e0b129d2fdc5395876fccc0a6265e01f1a1063a0f7dc6ef504bf939d759bcc442984c463650d6c0324f8bee813a4bef36551fd745b3b0e5774a10ab920a25f6be2c267449d7c27cecbe45c87c3651df7bdb7ed61a23d7f33d25a008e2154b2460290ed33c148f34dae0ae4b768718457db3ca11cdc7cba93cd9a0e2ba8eb6cf7ca76e9d00656b417af48a1a460385456660fb5746f0c5457f3189bfdf2101f0d24e657f358c4f113605b90d6137b13656d92f01693dd7cfcb8b1455d90c719d8ae754ac87cff188372f73508996723fa97ef1113ab5c9d647635b7da4acb1d278b1cfc56fee4a87861f55453473a44862a9ff5abedea24f3fb57e1466373d4cd2a3fe2533f402ad62e3d85850dcba4700e6567256b55d1d9b4be377681383e3ff22cc546c81a239516a26259bd620a52e4d45eb0ee8d9aaa7bd7a0dd88449a7cee60e7d0bbd678dc25c8ac9adb8f58e9b368737eb86353d6740e8ad535b5088bcbce5dcd5ebef9242ab9848ab742aae8f08df7680cb5129a1bede5cdee955b46df90037eed9b37cc2441f7b452968fcd69d040b1cd5ac22c8618c4b2e7dacaf5a8acc5765e924252881ee025fc5b49fe51a7d39e587592c272316d7ebc4b9fd00427997a1c243273f984c3b2a500f674d574c6e35e0e56077a88f2cb0fa72b56fdc9a981d17474b87187b4cac54e4dc110ea6608d531cb793f87a99c4fffa8f23ebd6fcd90eddb1956985369ea20cd9183a6bf4075d23fecfac79ae80c5a3442b72e78f9569d8d946a190883b203105ba7f48a87c3bf5987850f8d6c840d722df72f9fa0c74b89705ec3ca2af5db95847f35d3474adb64aface250245e8afcd1d737c67b0ee363fce60c413e409e71ce9aefef377ecf7b175934874ceff9d152937d2fc3e408a30acc4fb8942fea386fd99e96d92a88a335f879ad50f7946113a19c6948cbd95220623941408679ff887678525e32cb07ee610c7b867034cb2abde5962c8042a2625427ce5914fd7bb547ff83b748bc12f3313d860dc5cc85805ef7a280d849f1fdcd426a1c9515062a93183313b52b4349dd160df1a546cadfa3c84aae739af79e64610e54aa202856e02325edd3144caa8ef59e4ea69fe2ce163f76c4fd57e3472696479183ea8f7c6f7eb62476c5c537ae24139b7292177f4489be0a4b7307665831406105b8296b2c95aa5222af1f0927d273f7f7d0ed173493d122e0ac52a5b67a9e9d8ec5503e1ed9b9e250b950741522001719180e0877be90f7dbbd74279378f20249ef77b046959d2ca81a05fc1bcfc67ef86f1528a92abaeec52063dd27477bb43e407d90914b63bf8a00e0a5f7d2a43fe105b0c0c4307dd3281cbe297198b350fabaf811129ffbae10574dfc9536861f713a5cb9e5f102d455f67f40bd5ee79422d9ca1e993527a63071fc2850b2176a10f9135421c9b0fec0d213a93f26afb0dc51d753f759553029d81ae9ca077d2e34a9919e3f9bccc3e2006481767c3cce733d359c81fa91b872fac5848b4fd2841e144c5da89f1e740a5531b07132c5f98db388b9c0e0aad594324aa1406a5fae3d1e511347fd78b6ed5e410ec75f64fa2e51c57d9305b2d06ee5e7bcd898ef91758f507953677b6b83f8de36bb61f17e977ad279e4a952bd2a93ad431c6d1772eafece011bad0751fde2713c1d4f659a65785a233036bd284d84941d1fecf1624e651e983f713b814ce397d960c4ac7f23de502e2c3e0c0cd9141468d7f4d510d8e3dc9e6335b58473de7614f403792047f001b13fe30c0651805097884b2fb3040ea4b44aa3b9f68baa9b84023bde5fd995017e4fdfac5d3e0d5bdc28dc353f1dbebc47054451ac5b902bc25934115bb23c9b87cef8f4d653f3846da743c31800e5f7131bb8254e9efb308bad308843091a2ffbfb5f5ced98132481fd272fa36987308ea6ff2a5f742464713e5d776733e59590c670d4050abf347563e4b3b124b0bff37407166d347998ce84021d89e3c0d76bdfcf14a36f8e75c5d47244e2726a5d40caae617b8bf82aeb6fc9301f6d8beece174888dd822b6e63a679cc0276880f5d4a86384b142618e4d44fb733623fad9691868719569bd2e00dcc15cbb17cd0a16cc22e5561f55833492dee7c5d2151d2db18302293b37ff1653d48646ce15b10c7e597a15cfe6d2505a4883f90bd264200f51bb2472e25d38976e21da19732f2d8426961ecfe493ed4bc4bdb88cce33f335068a97b76ddad0a0b6ae2b8d02df807848b414f1d5ea296ed54992287ed9b40d7663374de34dba2d91f6037479602e3e49671b58c9d582c9a4267dfc8c381c9772c053ecbac4b7252d4f0afe32e983c188409d7376969b9bc4cd630d82d53b61611f443b6bd25574b0cf7c4ae06dcc9311df110fddc0853ce3f4cb516a21f42ce24f5fca96050a380ec91cdb0f6e33274844cbe54353426f2fb1273b1effb4f49d876fe3c705e9e98e188084ea6748c3b297a3b863ac593336bd22a57185ed99d9dda1a2ea8c2697417097c0d8744b4fa61d313c10bef550b0d921282ee5413da09e5dac4b36719545eeb8924237713230dd5afa99998d515760bf023ff1abf20ccf166b9d60d3a3f8e280a53efb4deab0a183ff13444c30cb6d1babb0848e91966f50ec0342ff94b6c6e9ea1fb7f804ef16b06299012a82894ead1f0ac98eef1367937e205c810da078c1a5a28c32ba39ea0db5ce6dae20230a88cf02fd150c656a9a40b0cedcb1d012d703a2a76b00c7349c00b1c57045c83bb70fac908c2b96bdebad5fef84a4ce3e77b0bc105ef4a2064dc97c043a4ba9a5053f55c9708194c6a33c84ecc6eccac366ae8f47463ef6c4bed43fb358660944b48ded20802ffa84e6f1199a64604897767864b9191d70fe1fdc019be16380eb6f7181e2024eaa534994a1d194eadc35f53957743f0ede57640ee36a42712618550dcf7fc770390a1877a6dcae44e7d10638920092c9b318e5604c60014f02a2d15ba04a46094e6a469af9f47b7ff405dc3676efdf41791de6ba0867006a90b463a0294f2e519110242bb44c91d9ab3f1d7c302585a3fe8ed8f3d3975b588fe6ade8d94605696ab71717bdd7102f4f426d6c4ee45b3d0f9e76caca58e4887824a815dc9a2222a77bba967a42676e1287bac75a66610c5bc3bb2815101a0e6519d03ccb0b6540e6017d3c8129516e5da0eb8550df221bd8683297f8e4d2ed1414742890c1b0fd1feae0d0de36af8d7717b8d7c06297b3a7f8e096ebf7548bfa79cc55230d05409d75cf33aefcfc83b3608f90c7853f1c11d60cebf034b294d188d58b445f4fb8dac139a37abf262406292460faa90d785d3df0fe712205a869e3e62bbb8069e394bcf6a00bedeec891ab4677772f2ba9c612c9aae26fb8f05c617db4eab94ad6c5caf62f616bd8dd53c61e1800d2fe69464dcc324f37f78eada95e923d5242f6e7b3c18df0d72cbdc94ceb6a7944b93934261624e5e7c3c241e055aa4e2f994ba06e62542b8902bf8cf7b0afefff6a2d23760ce7844b87b77677b0dba807ae6c43658c064ffec8052abb511aabd76b3903bdc31c48e88e72f4be9404dacb984eac7c8e34f65e14ce5880429bc51ae1a42413932847a57bb79535392c326a7c30076b43eb2984e4c8fcfa06c4698e00331a5df3b415298a02c1d4c19bab7f5d45d2b2c2211324f725ad10ff7e8441ed7e66036523a79542f4b1596ce1853c5bf551aa59216a62115809cb37332796c5078021e9e0b012efdf3bef83723599cfbae6d295d90998d9dc3b889034cdc1a531b1526860ba90a64ba2dd69e18fbf82a81003af7ea42591f7eb8f4c694654daef1ae3a2b9e83b14496b31ad5697e32c32fc645d3a9d3d76eb6962586c614a95c4bf7c968e8fc1de7e562c20ecf4f5e1ef427e1489e8291113e274f2d55831a34652cf4430bd1c1e01284e7ed58e8ff03eb55d940223601988cc8d6d62cb36911998626956339bbe19b5c56041321e66a2ed379c8e7e384dd1d6fa243d961e3920288cb394437881da814a525a3522f844b4c1b360fdfb772735344feb05d10bffc49578c8118fcac646f33bdd2b991df16f900dfff38f84ac9a653f39fd711de66f7fcba124a357ce37db97fc6810e355aaf58c3a861b7fed4b1d95a3594e355c8782a2cd7c9c75ac07f227835a29e7a8948d8ff76493a81e40504a1c2423810f80db0dffe07af11164f2c59286f97fc8269649434840b0891d1f0d4a7d8dd160acecb9b397182492fedb62a4e3b7d4d4d3cab2469aee20aa4f2e3abac3385a7794bbaa5a2f23633724358b71b41383dc83dd7693949f78370c2bc4aa15b2e790493808fb5243ec950c201513dd577d331f2e74c4ac5378c21bfa41bddb649175f95573989a7dac38d40f8356ff9ce9e5eb19ed39fbf098b745bafa906c4016b5fae1e901f2375815ac28e28ea39e87e425285bcc4f0751ad12848d239820aaedb1f95fdc3b890b0602d2487b7949e123fb511050f4defcbb6d06230f37a0843964a13194ad2e90e793b5ee463376d3ecd50e48723322271a640ba5e2dfd31806e2b3434d4b489aa8cf0b91e503224c337991da7dfb8117d1b539903597d17149d374793f51d3ed5cb6e8613cdcb1f752a96a69e51b9acd53a7e0f6e8ed05217e75fce719dc006ee0608eeccdd8a3c5fdc14166cfff0449b0ebe20070a119858294cd49a034b9265e41629fa49db09594637c6aa601285651724758233cc2ebf9e916860e8f2c490d6affee7bb2a8f1ca65cae44ada530775f5a2952c8977b364b9fb65d065829063284a3bf79378f24404206c6cdf1c814b2a588b9058e17fa98ac15419c9bb858d3907aebd7224d694a32b7bf29d89e038b5b4f61958d041f840d0f4809c9694ab00bba812e6bd7e53d4e1586e020076ac85d51abce0536c41c4fe5c17ab88ac67303c820f16d0941c7ca07edc30ecf59e526b43eedc3382cb9120614c54e98f24f9ab2ef36ee5f80de2eedad9cf630681edae675987e01c153b014986740b4a9049a0b079401196316e55d2142733036c31e84bcafa1d93b198ca4431c4cdcdc85c95746552743137084d0a752d9316855e82948e7f66bd3e8e28310a20c372e1bda35ec2f0df4d8fc7c05b6b71e0da28b1efab123391beab890b2e41a125bf3dbf4972083af912923768688d0a904ba5cfec57ad9b8236cb124ad0ad0d30ab951dd3e37592761107b7926788af4599c57dfca59c1a1dd394d8f590e3e0c1bbe52773ea45c6c1bda4b319e03815156379b0e376d982e5f271951cf3899c6d88ab31b9ca3c46f90901b5e459f6b12e9528f759654ae79f2092e6e26e10e05f6093962105cf13053ef924c8108bf2a5fc45a501e278ddac09cd44a85c94038bfdc8675605b21d52025a071716381d343d0fd14159e15a45a36ee87abfc0d3a5e5559c80242931672ed0ead0291b479ce7af9f9ddb2eba2720b661da494151ea1348a0af7c6159962684045de59fe921eb7ab947cdd623a2fdd9eb5bb3cc623ecf74b2785d3ace000cdf988c5b310fa258c99289bf9ccee94cb6786fab7a9b92df3e9e7fdd05c349af59f352c4d20525777e9a5de3fd833be735a44c2d27c9359c20a98f3c9c12cde4b717c09e21121afa815247da91afc0ac357a752137018096a7bcf6cfc8f34913f37bfb5bc7612bea7817d8acfc78a1a72040db05f6b15cd6cd010ac52414c19a11143771ebbf0f8989dd9746ace0724f137b520a7b30523a8d62691ac1752052a173f6d4fdd71610c5e1f1437366f1b4b1b8f6350625f8a723386266cbcb81b7dd993ec2b096973ae0a5b0beb8ee86ca62e938c7da3a0ce0c989f7cb0f10accde0b30b7e373f6b666017ff9f8c8d9f71cbf256fecdab6180701359d3e4b8a3583b31afbca0b900884e1b73515099f1637c62ee1daee5607b672830abc7d7aada162ad090f8a310faa29204fad9cfda79de52361cc7bcbf4286e160106d49de6db31a7132b0e2824f73e0a409f1bf6d3fae9dbe9d0947af84d4a486406109e360ab05be6492b92b77138a3ffd0b084768b09279b64587efa5b57d9a43f629cc33616d96f605ba77483a8f72b71460d390ffb2e6569513762cd39e2772a02ee67257c84623c8c5f239f6dd67e3e5ae94ebe4ac5bc796c4a9550481a5c68ec67585e81fc77b55ad5b44265872aa9cb587862fc1358fa8b6b8df5af48a640d796867149123cce928fa1be056a004efbc5cd15e6251682867339d8ee1be030413b134a132ff44bface108b6c348045b5b272bd20027805c6023574fc2a41471c418fa14c8dd08a4473061f7d16d0c682e2ef34f07840b861f19a73b1dacb9ef103b8a7412caf7679c577deed989d354d46e49d4a7635a74e5b6897dbe81f7384811e518a0b7c68776e3f3a49ff053b0906fb093196c7048f8efd0bcb7429c34465f8a8534beaf681c33f58765c2ee981ab3d0ae6c235f0fc658a7ed75b8ac1a77a91b851c5f0a003c73c05aee2ccecf642e02795a1347d7fa692602e7ac9c53b631a4913168ba8e916e65faabc5f561dc789a025d59f12bad030e875b8056f4ab820037f3bc2e1ab12a918be712e0ea01d3bfe78c19150673af69cf97c177082798c33c32bc2b494fa889518c82f2579f95ea715e0759e173376bf511552b69711453333f637eb3c4f5d509c00fc2f66260e55d5de2daef8bee42d438bf2429443efe034180f9f8fa2676604e82d1c6b20492f4fa67d379c9d686a81939728254fe8a76a7626d3984b05dfbbbf30f683794affe0740ad1152fd7641dfddd442dba744957f13ad0606b9954eaa621a8579ed2bf5747f43d2677e841c3db9003c7f0c96b8abbeafee6897e734f2980f2fad65102edba3e12882673976bdb22c50317e263b2f6938017a054ba56f2be67987e09e4abafb65800ffe8333a72553e196604f39fc3a0f97f2036a0130015644d3fbad7dcdc8468349861b1ef310592d39461a8bca6d935d641e20d7174e8f77bacdd3ede31dd165a58d10414852512acdbf52952519c921d20699bc4bbb1fd03984c267fc7af3b3f8772e63002dd08a139abe9f7f4a477f2e7896335b328820d4c4a5359cd197e8ef0788a9c81087252207243fc278e729e9e1e451d8c3496add68d7aeb1d58b3122e3d6cbaefeede556e799e6284757ebce33084604415feca91fdd9365776fe771fa020709829fb9cb68faf4bb7cb5261a55c11abbdbe6b63dbe6ea77db1cfb37ba14d9ddece1ca31b34be0d4366729d2ae5dea9e8224899b3fbf2411497168e698d8e44818678fe2492639a98c356b3f8d3cb23bff6c117123190eb118cf6fc6cf37965564c29667ab559ab2e36b12a48966cdff52febe9e781564fbdc8713ab0dfeb20839c2624eb8eb0be9fc4a25ebac1084857ba7abb96bdbda1c7e50926886253cad57a5c673024404a0047fc147b371dc9e2f318a305589c6609a711213b49c10d64437fe967cfbf502831f248150b80e2a584aed17af8a577cc95bd4e6224004a013abc5537ea75edcefa8b1d1452f27d79ba604fab7e466da7e19f4a754c0ee0c4967206ad1671e03eaf3a200ba6b7265c835d4e148f54e81744565aca911f26a951079577500f8ab9a695be23b54f2a93787e21f1ab4bb0a8e03a43a34fdfc5e5dceb4cafdd80f525f221333919485217b63499c51c251e570fe64331e89e330be4129b590c55a0d01949f7ec30a34119349294f4e9e4331b0ae496013c46e123809cf481c7b8a8f15d46887915cd3dfe49358670043a56afad2c8c49fcb21b721d190133969db30e9acecf44af23e91c96b4fd839d2f75e10aee862a89ab7ed21741f07c5d27c2eebb913835b0c892926d5fec054318b6413697a169c0e23e9c301568b64a15062d9b73cf37790f9a712c92f4a4429d8d6d1d5f15d71ca1d60934b379fbf41a0cbdfbd0d2e6ba1af35550a50261de5ae511fe352323d4eec3d8338ed3822a0ca160df13318c5d3e45896af8d98a62c8475f09235e16db935f5452b93698cc6911bc43ecab04c0578ef8279ac38629a756040c6346cdbd991f632076692d3d5d2a04fe9959ad5694a34d0de205ab4ce86f0f3d047ea1369e99d004d29d3ee71239c6f2a7339e80ed8f93560aed33f04189f8806ac7d696a154ebc84a6930f84ff33f6fef6fcd4a229d3a6c3f0d020e65c21947d096fbc9fc41d532ae8a40f22db33bbc5e71852befeebc059c49769990ad7253e78d4f956e61fea1acf5bb679f266f6736a7a73f74fbea2f6dd2a9cfba52ca6569f2830e9f83d3c89a511598774009edbac254df36a1eaf69ed2d3cc9c61a8f01923533c796ebb34b77e84c6b6a30580be4792008967198320297c0092acf010131fd99b86564f60af06250d292e441846742ca9a17fe3af030ca2bbf4be50f83e1702d72c4a28d81b400a874f246e4ed90a23901f5b9a5829a02b4f5aa7a5ccc0193074bc4e92c125129af619965c7a6f1ba550e36454bc39ae9e54072d9186c3a40fdd60c6c03f9226457f5d82b94298a1c5bb6b97219fd8a892d021c7ea70a200e4d505655c7c14008b428a2e935d9d75040683d82973400875197805c759d9ea598ff84e33391b91e4922581d51a05fd39b7e8ee72f017275fda1aa522b657eb5a7c09c2539355fcea6fae8a5c01e26dc8d56f45ea546ed6c8a94ac5b66cb7e41157b437c1e5e5a896eeaa3d508d86e68274678a372a3b0c4fca47ee3d951d737fa3d49fe3fd08331c783417109b12278d1463461875143519e7c2b65b7c87214aba8d741512c2b1f6ade4c7601f8a2538206480547b6765fb62ea97035b984ba57925c5ea893aa71290483425ade337c78e9d95265bcaa2b9cb4ab051949e7e411e0d78d21806b4eca097eaa55489e72c012eff08d79a5115b01ada00a602ba1245d50162ce0be5b46f85dd4ea05885969cd39b833176b78834b6e82ba4451ccad9c37c2257b4b5ffe8515dfc96f1609d06ec7c3bb06099a29e6cdbf0230e90bfd68158a342a0c35be1cfaf65c8c1d655ebce214fc79c3ea46ef7d3afca187b5824c80d2cc35f464dff6b5e462d9cd5107b1de0236bd08cb8344816855b80d188ac9234db850b948414fefda27710c1c57b3b4649f655c0151fb305f34af818e6b7945726ff07797b7b663875a133e227248d1a692a015cf8990c814325eb8a5cc4cbb134c947f50d1d9a05f58816e3c26978273e6640481d690342fffbe9dee3b299f1c2b7e82054eafd72ae56f575845f85da2bbb4844f58893bb77b9db7848c52dfedf62873efab76a9269a9d967d5a3b7f45747a567a6e0533fae8221fd72952b0c8f066f9a0845eccdd65b49c1e30b028ab10fdc223bd41cfafc50b8def2c5d3cc4522271ff525ac21fc38a43255820665af8da7e6602d815fe50b1e3ef5d04a797166432e235e4748c88842b51ff82cee6413f9a7894d78387efbae3534104a978454690a533a0356043291ed62c031a2ca65c4f024d083fef6d653039ce7f4b28e2ad4349521f474781a664ed133b0dd7746da539c9ded96676fd42a5d787f7d5fbe7480ba0c87da6b803bdc34a2b95d60a74a4251aab674c89f8844705b809a1917606438aa8053f4222983723d4a3412758b5406f17b447d22f6631ac06b148fcf7222d2516f9d97f9a51ef13f9abd319f211ffd07dba9e0c3cbd62ec250382e66abd9062f78358f82b659efa0fdf9fad7ab59fa5c852b5b12f694081ae6d2ae5c0d3e311f003a9b4cea01cdf7b7e6a331a3c0f617a551686745cd5a73fbbe6d1c21e7537be31f1428c45baff173d9c0e517cfeb29aca46a707b5985781eb824087e234c6b1a25a0cf1877d5db5add56c89eef20cfa641f3eeb20003f983f24f00962facdab464d885329095ba9cfc88fb078fda7b96bd596d83271e85f527d0eb775250a65f142276cd6c1abad1314fa579b23d48535725d05c80b1f70712d675dc59b6eb2c3ae29136f643d3c4d8979a35ca1ddc02fcb667c417e5e4220164746d736a94ef6e63ead1bec1a713eb53c793cd83ef734c4e25e61a9367a0630e89ee0fd2cdc80b445ed164279f170961cb03b8f27b412b8d02dc0006dbf3a85d1cd3be6e2d7fa361d4933b7eb6c78c2d8a39f241c5c3e4011c53a50ff440a0c605c8088ac4a6f200d3c76aadd7974aebea762306d57a707b69999de6a8601c13b69eb67752205c83d6f38f6779d262229b80f477769c3dd38bd85a292fad4aca70283fcea191ce510c2f82fd9483bfe3af8d8d2163f4f00a66a787ca5bd3b6bebd4824fb49d4033c8bd7d3d72f7ca2a6295baf19adb644149d573a0ee9145558f5140e29bbbf379f5ac48e01118b50cf12ff936fa08d46448c5f78215e9393edca3eeab68d67c180e7d2e30e6c638cb886ef9eb47059fe3c91856c0e67d48df004bac2e9270175a0362fdf972523b38b8fa3111f3229186ed9fa656102617296b78c80103320fbc9d02cf92be7b0706077ef1af497386fa3316d6b199dbed6551ea96a6bbe0b9e79a4e1751aa41292606ebc89ac923c6d63d6e59489af031bec639ee748891382150849be648c72803ce635d224985c7bde32c55f9f0222d2b7cf544e2a75ca58d2a190419976847893e8da73149a83e6a76f696619f4b72f27eea0af9d2ef6e14697170738be392c33bf2e05a968a93f1a6734d56736ea0792c1fdb4b1b1ad58f8ec08dcac711194170f2bb90ca1e8651eafcdfd93f82b97bc69d51ea22dfe96da95a11649146bb5b0a7c5c61a3c6d471ab72e1e84c093f62919fa34fd7edc8808eb45f14fb67c39904049993286047f94d5738b30aa742a7912f089ef400ce3af0eb95419fc0e063e175ffec0dfb477feaffd08b0ac726c0fd70594658bd9525ab37d30f94c47278ae8988a557473d547982670cc3c8929f5e7b03462d2b7ed316d0977f37b4bf09d7fcfad023691fbdc340326e8d7d87e80052126014b671f82a9278d66456b77d1f09d871e928797502344ac65447eab823911f3e3198d8289a4f2934e7252d29eb6e5f2924c275bce6016725dee40ce93ee628b7bbdf6943fef0bcd578e3c45bd6aaded8293bc68d424f25649ee035a85dc28abbded3076f2cbdc4f5df6ab8f23fbeb104f28b29b85fc6482e1aa19b01692ce3c1befb781fee0337928f71d9e72748db20664464de568ab3e3a2f8b2c28b6c182c2c933973f8f5641521ac1d3d7bc372b144c8f30936c32eef9888da328142dcd556b100ff4c6b430f7f4633c9e45b922bbb201abe248de6d55afeceb8e9a6f7688802e40402b3f5bda0160fb91680fcbcefce5f98146185ee95fe0c3f6e33c44668a45230b84435d7889bbafd7aa80aed29c748ad71bd36cb63591460384a0645722ae829098e9f87b777761ba5c96f7d70b38dfaa35f1731d5a0fd935c9393f4c2c70b52e3e23fd382f54861a5a7748f0d93e8719e53adc693594a031672fc3b0362a1c796b849b4d58892cc8dc08103973a3984d99d67f6037c04a979a483e3675f13961c5cda46293c11c9a16e95fb525427f11ccd30bfd5d7008830bee0484d5e11f5696a25a78cd0fb94d8954237c3c03358db03cc912b4cc27e45181f52e6647be936b06b8f8cfdbe6d60f1f1aec2335510c8f60cba6380fe684cb2744a5ff854f245769f984539218b4b40fe612036c34d81df5393b8678ba6f0bfd645ad9b83f4f1a07b947ec2cfac994de46a9eeb6d722183118b16f21f063cf2aedc09580556b34c27b8ef7d329974998afc3fbb348a3d134120144393b5285df1b6b173a87b1c20e4b9bdcd23297db9b1b56964e55555d4eef0ea05f96daecb9fdaf1aa55237e27fc070e62c167008b7daebeed62a811b2d1f9bcfa9eb9b9a80dfcadef4413fb607086f58315de728cf94ca32220d7ced7a41f8ba473be793c6508567e885f888c55d0dd544c2824d57323941c202794cd51d0e772f61b445ed0cecea89cef6ce59764d536b6298804a1ac881575eb1d7cdc705e665eedaf4117e97fee7bf5ff9b030722a070a3a6cd3a55774bd82b73c0e301220da586970c7dddad1be2d4e9991c20e853ae1fc4d5f592995be3d2611fd23d62c4716ab64c0a2c045a9d42e821b6d2520f15d0e97981140c83287c5b7e240ed2678a2d0018f3ae89903a8a5ecfb7948e2cd98947b31ecc21b6c94493a180968ba7a1a3d4a992220d22305612be1505a78d413680f5f4d27765e7410f80a063147d0399100b15db15b0e0a59768fd96b73b140264d02b142a93aa6aeecf4441949cebda3192118502ffe64b8abf5748681b9d2e05721b291eaa97b81a247b7ba333f027e31307cd9cc66cec62173d4c4f1c3ca594e4e859f25dc687a600cc2b719f4cad412faf795225ce97b33882806a1ec8581d672179599d2b73b639c5666738706547421f3925d160b0f15104966089c066e658273f2e52c4b5853c2fb099b25dfa72116296ce61491a6b31e4390eb67939710ddfc842373483afb509dd85dd86db6db50b143a6a904eb5b3ad5bdb62b7d7e14408370f2620b9220a40a5451bc79c72f0fd4aac7863f2b7424a8c940a86786aed277d7cd18c385f079399f9598df2777de3136cd42f9b1761da50f73c7b128cf16e202694487c788bf98b1bf549926d4b92b139832f45828337701791216a2a5344a8e194e2cf4859e79614bd5b929c60136733f71c785c0977897dbe9341bb20ebda3bfb1c8e43d4f57a9c10909bf7a72e1550fb5e6dc51cc2cac52b88efaa86f0562cbf7675ba2de2059384e2e44d103e701eb57a28648b7016eab9c2fc343ebdd1415848cac790be9ce815f60dbde842a6771373ab45863c95567c5aa3db0b3c56ddc8c601a81647d4d8f91d348d95f946bfaa8d4166b3308012ac56648a64d4cb8ec195055095bb7e8dd242af1d1ddf5b4d09a6d782679c92d9c5b59bb92ea0d955d1517386ce0e79108a28b852d586e54ffff727fa28d51acc2e2abc5230a2686f181c13b43ef676103bdaec6e6b400c8b0a4c06e14577a5deeeebe1f3820f24812b880f165d609ec09ddf62e7a2d36e0603ef5f14ec68a3e69173b14c2953d093014bcb8232bb471cbb21995e281f5fef7f8cda0e39d421ee581d2a1a9a686bcd564e14ea239a34a1e9aa415fe7b1fd699ef5c89f8b42c896f17cf9f93f49cf9af26c1dfc960c16d39f8333cca1995d2a7f18eb31e5bb9fe8eaae724956f57790af83ee23d691886720d1941757c9078e521767ad73c6181bda7619a5bb4e906e9a379694ae13d65800f57f357682bde1e38c5b326a4d3636b4888c79fd3cef412fcf46ca146340feb87a985f7c1a3b87aa6be5e34e025547576eee06dff1bae3b0f90d66967a95ccdb0d2962684f43d54d4b0adeb778836cceb66cf8a572c04a2f3adcfd33dd570dd97d1a3393090c519cdd505536faed977142291a787f945f3b750a4f55c0f70dc72cccb097623244cb09062503053a52ebd048b6ce4a2fa36619cbf8ef298073d30c666c13d05d55635a854512c91e683a440b2bd38e72df657fdfe8038f1b8ad72d26b8b31d05802082d5f7b50267777a7dc00c2dfe80446ba26e2a5dca6da05d61a8b7f9f9fb15d9538d35d5fda49170936dfd807888ee94f7c991e1744dd2383112b4e401863cfe12cd15a4b7431486bc95c328b9e63c503871d10e0e04aff3f98174f9123bfbfac2bd58f0085e3dfdde9747adba2e02d557be8c7b7ca5a5975f2d999946cf5610292403609d0f5cb695f847056194ea242316ae795efec2719afaab8da95487f614404e7e38e3b8e07c6a3f9a7dad1ed3d0ef77f7e1cd22aed0b4162df95136bc075f4efada7e8da4fa64908628c2c79fd5edfc9b827a0a78e568cf1bbe9ae0250c233c25112c9753903b81cd870b67aa887edd7ffa1fba04603c97b6afe5cf2f9c332ab843d30d7a6d65f73261660247db0ecc1f5a9e343c930e0ed51f61772b5ca78afdc4ee9dd17932aa31e68514d568a81bcbb712bc2f77ca759023a5479ae0b0f4ed6ea5a93e75bff9bf9dc300b50bd75aa2349fc914ba36dc8b5e7f7731cf6175a120569c1ce68cb0a9b3bd986d88fa70d0c4a6641877c5af57f3ff7f9e595f0159568c9e6715f6496587f565ca58b98b2e77c330f9ef751d3781784c933dfc64160324b12b95bbf679160bbac9d8f15caf8aeffde4a0b807dc54f55f108d430534d5f452a39a5b2befcd52e695053478e4a27a667c9d086aafa268aa68a8a3c31a2aed6dc73485d4622837a08341a080da6d45a26e4f3d5135664ec8834bd5ac8de28d0f37355d34e9b8d56353aa9c2b778faad9998fef75eba6ea78385739efece814a004a957f8f0b04f7f0b487187c5dd291a4b1250f641cc25970b41997a3bcf15a4821a25c6021b7d702ec2f1681df9acb4428ba718108dc10ec7ee0bb4def2aa86c94654483689b068ae861787ae000c126fdf9930e2f880c10de5305adab539b42eefbee74c6175213fe9f47950f1012773c8ec23c7a7f29c052216160f859621ed95daf0685f42039267ffcc891a047ed2968edfbc07bb61c6458f5c3a0a381fa3290310312bbebc3131bfd02eab2e7bbd5627942b645f082b6f2bc57f3773d4302d15c97a81b1b5d807705c35a5f1c7d4adea8122fbc7a123ca307cf80b4395fef10938f626937d52713070ba3be0f39dfc25d936049acf1e6b275239fae4ca433944561b3e074d07248f564fcb749942e17d789aefa8f47f93acc31b33ce793cd0f8bd4ca58837fe5f7730882a2b1cf73884842d5a273b9fb455b51c3751f56833502bb1c4a9ee86775261c6061293bb83d988b429e73ce0ad42b912ad4d3a2e22a1fc98ddf056193554e050ef990f8a23f34811cc3d79127b724426eae3f66648ae027c37c05ceaa00210e17e6c7561838328b0a8ef4e7a72349f4115cfbf9c0c51a7880224295999880cb625c5f0f9331fcd6a36cbe6e56b27dba0323366524bd97870f20619fc374ee7a7ba1c7292b9c78fa95807f321c81079f7790bb4fc0480e8b1c78d4830c3e9f70bbc04e16cff881eb6041fd42d25cd59be92b911681e6de0c9d164443000d20c4f7fbd1e8887201f675c154a827a1756fa15fdf0d26e8ad5c7fd4d4cc422f102e16abbe609a1fefcce77e0bba07a8b150ceefe623ede822df9ee642b6d4c831dc8997592d72c40fd6e73df4767397af6ccab1420c3a801dc8a7faf22422952ac63f8be70d6266d42b1c81f094263ded95027e4e8c28e9772fc77b8d16d7f6f805982fce30fa3b8fbd562c1d57fb43afe2feaf8cb809deda1bf9ad5e4ceb9ecdec344b7b76b62036dc5e1b7cc3f768b7d5fcd19a0952c438024bcd7fe1d1b0c7f79aa1bf6b39069d03a185235e744cba2b62b09f8abfe13421067d78ab125149531dca9dd8839bf49841a48415b5aa21b8a8882f976177d042c713cd91f33baa0281ceb2b8f94be3fadf341a8b25b1832c574ba83874f994a327d058bc453d2954ae16c0f6c6be8d0c069c5af85609cb2e8fd984128582c58aa9c43d5c4d6cdbb60382982cac0c9220da663c32a887b8a069604a8e8ee45390506f49b778acceba7a84dc01571790ce40fa42a776e9fb921c23fca9dc8a32ff23a1cc44bd31a7c124b0ee4c05d76aace62410efd025193cdd058b8d5e8508ce52cc64de77469fb6421309e770048d51fa4333d9b8eb6a08a8c1f37f1b6be8b1d8d33a08833da490dd28baafc5424004dba6188d486e1d5c1a3e3d3b9a5274e4aff9fe1ae407db67c294077d0907bba60d085dcb5f91c360cc3c550510034ba20a252606e6db8b64d40695e84ce8a8517e82932d00d8ce3c8059d9baced71f9416a27741296908e823777d661f2a568601a2786ce2683796c8679e2a1cd83bc38f28ae84e123e23507e7f005361c0643d18892a0347a65af0aabbdca65da035efe165c10e31bb975b211a0e9737752bbd505b93d87179e94d36c5fde621ff0eac3fbffcd81ea9d908e44bd7d0031bdd4538c2dd2f931a13e647b3332a095ae53f6766c9d2106da3f084df9f7148f83eed1a1f22cc6ab29724772d6601aacc2c859d978808f6fbde93b6d80e7b49ae5f7025f9c1e1290d79d58248e371f0847cc6fc56226ed4cd29f8609f705084a945456d8642d0621f8cc7970a7861c0d8f437c08b98a5781ed185925fd64562bdb51f06f3b70cffc52231795d85859f81728087bb8248d6e7fd4e51eccc31b7ac491fa8baa1237cd1692ea882811a16cfbe7ce5cb8ea47d50222c8b8bc915eacd825569a256141734608b93855a9059cc123086dc1433eb34e8f4493c6c4aa15b4f170f5fddd08f6ba45e1fe81f910b137ef32b37411481d1ea5eb0ec4fdb3ad94329a5a4d3e5727a04e764285a399509ac668e06150028c7251bec002a77985492dffce6a4ea4a7ef5da3a23a2792e2e0add07062719c1bc0372d8fe2fe4f046131c01ac1d8fdf493900db537ad227809a830b198a35a15f8db52b2310e33bc4e23dde5f16951f83d0ab55a28fdea2b715d269035a0da6cc540bfe07f3d08b5841675c355c9b4d7bdc5a03109a6c450a8b840dbae29221b392e3740850603c1617abdfb266b9955eff4b0e9cb530ad17c5bb2172023b09872739473afde63854002eb30f80901cae1a3c231d8056f0fbcf45f9e11b0f8f08a2de5b6cbca03bc81b5f2a75dd293f440f5b7ea5ebd3324e4550c676ce4af7e7bb45c69bc387362e7f59c0be856aa1b102d5fe0acfaeb4fd968b576285a843c62b90f11a3fe4ccc4be9268b827e162ad2e1e73642f738e00729fb6312b942648fbc3467a9ce59d7924299f83f7a501d9342d671080e87a49c39c9e06ee21a6c33c6d891e87572026af42d23fc13a047d9d3f6a85c99021f5568bdad272fa1614d9f29a148d8876b54f9b9987557ea6270678d6f668bf4dfd3e54308824927aff33231b73c58220eb4f538391eb880f2558908cb30514c95431d85a644ff78acb82d37f01e808c582d1d753180109a5300897b2de0b17c6db5b639e328631b75b17151e52b4942e7b5e98990e30eecbfd00757d36520abcf2f0cef1cc24e1acc94f83f7e48411f675ee44ed3cab403d1e52d6706efaa355d28e074151c07c11b4d2542fd2c33ccd1b57462c708223cf33a741bb2aebfde39df39b6cce31a8f1a6f3e460fd4e56603c79354a68f482954c58db321dd7570932089af78239d65832cef8846e382ac5204d45796248bb13fd9c7a52b286b74126bd548fb1336c624da303b13af023e1b7190ba55bc4106696c94745229d1d387d04ed4745c9befe769c90a23cef5a8611b58d72a543d5f4d841a3aba4b77c2c0422eba3d8d0ab200cd7c7f2ccc524517aab9d9d9ba013e2deb0e5e85dcd012a702394cd0a5f77f3c83a6d2230647aabffef0cc7a24bb946c47a2b34e2c7818f3955843f2b196eb16b39e891a44aa5d41f7d16737826b695dd5287f11c39fe1c344c7fd1ac1c5e794afd09ddab1a2305f4cdfcce7142c97b9d68faed60f401c8ee701e5dfab9e992476508024c5288ec742041ef279455a10946cd0a771bc9462002281de8cb2de0fcb9921c4492afff9a8f7e9017bda5a713409a3060dff72034ed510fb333a3b6d763cca53cf6b9ddf78fe8e16aedd175289ad02ae77c2e5c43eee9ad4d076aa0472cb579bbd11bd7637f9aacafe1dbcd97c6f906660d5a97d5229ceca0ef16d4ac22d2eb69b8f5890ebd030d96645e920a34ccd2466d462375712220ef59ec8bd454f586f19089d7f09ddccaf8809af521865b5449442d2b2a60ea5a9ba3c15bbccaf461827e576b495a4dc57dafe1df2c821f124a3e7b1b98d03e33610b4043594ee0199c4ffab809adf346816cb0046cd9e491c02afe3c44aa2855f8649f4fc9a07c35416fd976a72b60e695843ede4a8e1643d635bc3f2579115c804fda9a913d04ec2b901ca1f747ad9145295af6816035aec19014e137beec45caedfab08a58263b42deaff45797986929e652e90146b2c372b080069a13fac61223377b703514540b1a5feff620886cec025e0b847d52cf531949bd4945042332e8bd143f705a850caccae245dbf789db3e75c9f92d48adaa19e5d7ddf7b3ce3c8efe61a40c22870031b14fda24ea064c75ba3f094f7ca679f1772bdf16df308548e8eea8ea685d39d7a7e381a279b55311e5be203ef26c1d4bed226cc734dfe029f929ad48a4ac71c532b686adf9e0e0c067169a40d119d6e59ef32d81bb5cb829287e92197f02865d85457956246558871c1ecf679b6da262e949c97a3e17aa3be92d9eb57e4222a0a65fe9d9be319dacc17622895ff04e5293bfb21ecf7e3914b0103bbcb712d32abf8bff6de9597ae044a4138db4bda7580753366c479b00deb8f5afe7f4020a19c12202f4cc21878b7fc56e7ceff4401ed4b1f88c53f705380885741ad4ed3208ce357cb6f2dc1d25b2b4b736b1bc5aed1acbf6d8ab6a411c37d52f1103ec2207d3dc94d17b47312587558d508d8d2b80583c91d172005fa5583a826d5ae933f6510ec2096b7a233b8d37a593cfa8229829b769424b9dbdbeca2df2925e46bb68dff5e775e196fec7fd7e82ca7cf8f055b522b017135521184095657b87f1c645878c5a508c8cc70d7e82e7ddda3afbeba8d507fe98724cd08f7a41824c9a95cf82f81f7b4e5d957c6331757d2177195f0ef03cf9683407cbacfc0ef6bdc51604b6dc77c8a500086349169df7a1610828ef6bf92322a79fb669c3442f07d652e9264339be6c54755356dadc4669773a67a582fa6af5d6d831e2c7761bef3326575c387dcd6c6853da7606500055210b4fe9f2a9da344172ba7d291639a89d2485f3bc473d3a761e50dbb05348282ce0094dd2017de3f06f33a11f8f4353301f108380fb82a77498b1ec085502b2820b66b2315ac62f806a6cdb69ec5f97423d3550b40c092a1e138b13ab7cc4cd27b47c3431247cf2a6efa5e4f86a64600d35eec6d88e4387895a1ae641a571784db981c74a37f9aec4a9193eb6b54b639ab6ad75ab4ad5c65313492644404c41337585dea45d4ba71518a5984ee97eea96ea8ab4896a89e90512728718fab5efca87f3d91008ba8f4fc0066142279c534d17facd7f2fe447e11d467b14756cf26dd13f5b51b993202646ef5571222fca4e5d55b5a1d2c09dacf4ae5aac02e68fec44acf0c7442e441d3b7fa55cfaebbdca6f9ec66cdc7d3decf3bec7c4582dbce6af1cb57dddbb785ac0012608b97fb5a324754bb93ceda62eca7ec6bd777ab6bcf9ad785998f1d7311b124ebd8c1a59d3a8292740f8b7e65835424c24733a5e30f09cb44e86b8df9e452cc371134e2bd90105504d84b693f92eb880a820d2895f512659112df7304cf257a54c17b6ec84c56daab1622ea7146aae4642cd6200553bb611a11b5e61aacd2896e2b9912e222cc7835bc9e7e5981e89c49731bb508139a2056e4a412af0137830064355b8c9a7878dc9e7a9d7f19969c45cacbb8397e5bdcf1671c344564a284f1bff991298248ac05799246b54d2aaca39072be64302d0dd8ffedd15cd2b47722c394f18b310487ad3fc1b6742c48193f5e23dc3802aa35dca9095263817d3398850fa65bb1f5a45864f2824b9a3f2165a3eb4d663992271351e224ee310636e978691e6dbbdde67f0f265b1833fc55f08ad526a2b3aeebf0506e2f67c6899ee313e1f398f2433ca5bf44ae3329c583526ce2ca2b92ab446526c7d6ae599a6fddac4880a1ed51bc85837c298174d059664367e8726eedb2e779e848bb22201fd79b6d725b123cb9e8a4f7763e9c773af7048dc9ee12f30345061f53eaca4ce2b3ec15ec00aab27caa19d13e332eb79c80c6ba79a25daefebcc2c3f97e9fefa7d69c78730b39fb82ae176e532336a1ed4a37e20bb2cf4fe2fa6aa38d4bd6e2a011d3a9a4dc01fe4ba24c9ac708b10e2f0ae2e21775275dfa97a35580537222a8e4f410060107a7e688c5abb3dc0929d4a4c8954331d907a23bf9113286fa0a6a88828ac3c639e9c57a309b22507046da44a10fbf33e7e9e0bad65400e63274b3ec70e3617890a4fb489c73d925b1c987140a807c1a7bdc46bbe328a783c5c8841fc5e41d09c9edd754d716a390f791df5b352c705e0503f8a2567a8a24f7d4b874dfab4198ba01cf19c48d9dbcb7f5f0d83f4f102cb8c9b736f51516d7736972882de0df957a3d266c642f3893364667f36af9855c4c82bcb842e8c487fc59c2d927496fee0f76e3e0e9fe89c9e8e68022f3082016c6eeabce8dc144a59095956deb83f48d8235a2772dc66d18cdc27fa379d50ecd8d3ae9ae42370206b1370722a3a414bf3d5cb18c2c5ec03530f0c84a1b19caecceed63c440c55f9c5338ced5f7da2b2da4d236f6350060277c50abbc29fdda56e49e1b8b0f677821b36bbfc0fa90d9d64540b98bd99faa2bf550678ab2539c419f138741f0c5e73eb51bfa21182f9e7fd28dbe996863707f212553d296ec38e7fe5882a34d859d11817479f137247ec9a61a1f90a10a3f6b492d7412f12973d7853673ee156c1ec4b5769e768f54c7c7baf4d73302e7555f1f5b564c819e8ebb21286df0734ca6871a162306b5b9d1a2c44d8199e9e10bfe7b3dd60b750b30e76df75eb605e462776715326204b6c4c1c1eaa8595cb9ebd886421b1753c597631e5c7b2ab37e76968bbbce46cde31edf96001592b86f2bb8d108405d223d66d1e9759547e1c217e66c8bd92146a9bc3922513b372317bfd8d45a612a64f899b0206a8d6f6a257e82b5188b9f5e8fcfd9b47f25ae5846f68b5e68ff7ddde5865c23d5ad617bd100db545d019409b57008a8a8957342b35380afe3d03604f6edf966b44899a104ab936866844c2d681394364fa8a0f62441b3f517e0eaa4ca391218ea3eb131e9577b914ba30e6fece5c08073e628b42e55d3b4048fbdfa97a4826576360e1dbe7658ff0519e03cca03f7be0db038b33c966eb82efbcdd47df1c9a414bfbff127a028a83d7775309e2e7f923a9e50fa9cb6137b3a0c444726415547042fac369d4668dcd40d9e8b0310c4d12fb33bdf333a1bb532fe45661446055dead73f88f6032f7bcc73c3e7c8ba075acfa8a6593787be6eb07df85a414ea4ef28a4fb9411445c1c8dc846bbe3fbeda55a03f0debe9f385c3c16b626dda736b5b6772219ef43f0157febaca71a4be950ccc95306b13613bde86b470752ade0829bae67665177bffa2a8533cd6a64dddc5c5595a724e20ee05f01eebbbb5163ebfd4bb55cdedea33eea670354d0a85a242150987e8a49ad6a1b38e8df04263a8f855abeb2ebafb8e0f07264b1253867d86ffcc600f6d421779f5d1c715037ffe660469f1dfa51f0cd3bc3ae6fdd626e06eaf8349aaa64cb3576a2578477d2d979ac19763c7ba9c3e06ee4ec66afed2d7fe390ccad9e33830ac078cc4f568b02086f89d252ed949236735f48b34a7237c38ef94da4deb9ff65f4b6c950b479eb1a52285b41839f40a88e06c224beeea0a9e1702cfe0cbe2f8308e1c6ef0dd9505004356812c3610f8be138e579ed96f8341fbd35a41e584329cc7b0d512401fe116025883f58e9bd5943e6fa198c83aee7fdc10c778f077d8e1e65ebed6c3bcfa6959f9d9210758a992a6756309dfb913d8a37f7d991d934dc3df645e0bd972a69afcd443b0545e43960264d12ecaa24fd688ea7faed1bce18c0b4d7c9dda05a804ae4a4cafb2bd32ae505ab29e8db1193dc6b9e0210e0ab7d352a2745516bbaf39561ccc5ca9bab2ccb84eebf977c26ad71d357d61eaa8075be34bf33be3eb0d59cfca86a3e267ce2a5480e4be972ec2e8e3433d2932fe6928830ebfddb468466c78f272037893f6212e43025154c62612268056c7cd84a5e36ae36c5ebdf0c9c3bf5d7e652affe2899bb12be997a50796e24aac7c61785a647352c3c191b0d38a36848aaca390749a0b69857c52fb3b9310eeadaf63053dcf232d838da22554f82a5e21e689ae6a4236cc2ee73d684f448e12f5dc94aa8dc068336bb20da10020e70d54b9c11aeb99cdd1a1a2d90519f2169e6579df62b48f32e7abb514841d235dd32f5bb5418fec480fcc62a3936643d97e63a69509607a304cd2abcfe3d6b1d1745ee736180d2b0cc63cd6be632e83f08a6da33534494980e48357508ee6869edc908476cd017ad6d161d110d85b53815e6240ecae23f77dcb5c5c118c845b08fa311bda05dbf345d527ba74f554f6522278eeb4deb5d083816d865f3a4a8cf62125f6ac843f6dea67de198ec2af8de583097e22c15edcbeb50a30abbb27ceb86367ef68c6abf7689f23a58bcf7a057a86f0b1c31963ec8a770d17ac6bf87acacb5cae3917cb82403ee4445404c9594bba4d66a67085b4d39d4ddd43b3367a803dea67fcb0c938e8afafdc55b22b01edeee8ba9d4917d8dea94003aadb520e3d04efbdec35a4f7c6272509608498bd162fac339c58d62fa62ab0cfe2b94aefded3e578f6637220f07740f478ca3992177952fa9d92e119472709172360b9874360f4aeb499bb46e11cc35c75497365014b0f2947aef135e349734705a08a176e075f8ac08d42be164c173b2f7d23e3c4caa56afb087b1aace50253de1be9a178691ac5a7a686e8e3bcaef6956de7050d0dfd9c5a9003c657c72d76747b08c688ace64978852472050510ffb256f018ef7e9bc3cd5106a815f3f03fe399976b8a34a3d2cedff9e8c49cfb75ffa44c7d17dcdc6c962e095dad10ccf3a70b231b4f94200a555b63d8a0ee6c5e3a928bde50c461a9b6c4c0c7693eba7dca1af44b5b810288e785da7f228a51db6db07c2b3aebc59d9d807f8a92a536a2b7c5091e33ea962e22237cb9c5e8d394e2aadbec135ae972d32aba751e87d30996eb9f6466e0d403aafa2b4aa442e223bd1e991d825e2ed16d17168860a58865fe4c42ea904a11ec7ded59473d0225b862aa3d71a83c7864ee5eb800a0d6c389c5f98dad6b5a3f7f01364b494815df3db38abea212052250e08f6292128248723c43bec40642e1856624ec26964bd2b927d21a9d59b7bce032c5d86ff1e88da0239161ad36de071619a4bc1910fad327265b15b0c3b9f62d8d4ed5eb7b4d3fdaa4275537b655083911e0407e1f9af2f0c417e374a23dbe3b08ca3d4d713f5f434b06e5fd7ac6170435bb0cdf3d081ae6c2046cb44a7f2da8be77bf0ff101d2ea4703aa6f7d68637b8598693f6592d656bd53564d33fe23853a67bedcaba75504d56058a6671fa40bd7a4b282048174fb6fc20f14041bd56a25ab5a248dd16a9ea0e61b9c69f208e3d16eab6ddbec5a68c12607aa5d7a518cb127733f8598c221bb236a69b80f24fbec229b62e417e41f71fe978febb397617fa7ad2ddbd02b4d4e9199a8925071c2a71d727090fffc24796bb9334667da39e32713aaa67df77e0da611269e8620e561d8b2e1c69c3e707a0c2f22ac9cba551989e1357769e3922f3d9ce0dd76390ed0b97dd2b24defe08ebec917e0ade953a8ef4cbadca97de5b27f000cdebf56b47685a3c627870e103ef7ae2a2179227e0d993a3c434620a9b2e83aab3ab6724c847439c92c8a69bdd05dc46a4256534bd0a26923e35b9f8be7c22a8723f4c98f5a1dfa90b06fb3fe50734ba2145720e6ee057eb3f507dddef11c8473ed386b79e0ddf3c678ba81654f4265034ec52ad31594abddf08081c74a29defeae2a27015298a61b13b302be78bd275430828ab4a837f55b009612a3890bd3d3e2ed2ad9d6131c16676c984e0c1d7ade52ddb811412e36ca70a3564fbc237d63743c417e7ccc09b4b18a91d4cf12aa8a4125c616ab16d7dc142f26bf9470ad683df66f4c660cbce71099c441dc38f5cb14078b8e1dbf1ffff694488379ce5727ff208c727f2199b9acda64fa24556394dfe440043726e339b7dbeab04fa5c6f2ea0d8a45e4356a70e9223b2d81b114ad796f5356acb6ea1bc36bfd634b9fe7a4878dc3d2c417509b0c0c1c0bbbfb5d89ecedf3acc6f5ebdc39552c15ddc3772478d81d9a71af2f0aa7ebeca26d080d87449397c0c37157f556bd9f0b91a3ae67095a0420255a52e79c5097649ef78933e538ffa85e0c3622b79e7f488171129dfad49e44c97530adaaaa3146b78b98a2e1707eec4a83da20ac5a07378f8fd58eb385544b61fcf941601f31a27b42036eab0849e759ea84c3d24ca64bee5597bc68604e5a4917ce3eb8f7d2edea0d81e0e885cc499597e6ba65efda8419a503189faf91ea79c0b62f57639654a0abbc8131c098f45db289a0a0e132f3d03254a113845da61ca567dafce46ddf44fd0fb877784d57a1e0dd016e7ea1c6338e04dea95b7ee5206d54302ef510d38063f949d3d32a7050e99fd1a9250d2063f636cc3e0d4b65c2d3d163fe7e38e17d13f10d641e1856aebeaa00e398839505ed8e3fa1fdb9282e0d2bb70c1abc6c40d0851edd979456f8d51bf41c2470c05f578ae70011a860a10662b5e75905a9ee0e8a3a0b491921c92c42fb25c6c95875afc7801e96c58adbfbfa8688b7a5c616177aba9203a6cf61045a47c6ff017bba3cd94afe34146f7ffdf05bdd8a7377fb10e9e7af818ab1edf0da803a443e00a8bd60fc0720f0473688f6375443bcdd12cf63ed72e508724b821ab2d04f009c5966cc1c33953043ea012a80351a92b760ad1d9ce7bc8236043bd0af8a599bd0d2d31dc7e7fdca43432490d3434be96fc6789577942b4a34b7816acf0bc0d53d203c7db8bd3d5cbedf2eed2f0c3425165dc7c46fe277fffd70fce41463179faa2fff7ed66a274bf9ee29f20f62663e26e109423267097609515c555ef0f1655cf382a646dfca610912aab5aff80837b138aa224e9e2d32052c74bad142813edbd4457d5bcac6630c3f1616f2b2be3314b583699f3b16f4e721d13c807e669ae3d13fe70d747f8d3e55479a7f99f85bd4aeb5944869e00ef687407c0f9fd44004441781ea20e8d6c38d0c2ed6dfbc79b025ae070ac88cb88ef4d5f69871a9d68529f01459d8fb5d7d13cf0059c715cd1c0f1ab4fdf8d5a17a32969acd6851abe7f5eab197c06b60b43e26fef96c490f4ec1c857529fac531347f503621a55164b4c033b6ea6c0b4251af9a3212166abd3ded20bee8cf15777ed71547594225f9837e95c482da0ab3e6960b01d9d9eebe40ba7214255c3d592af163c520cad694d638baaf5565e6553d11dfc5fc246327151968a895726ec7c3ddcb11ff49f03b87ddd9416d68f7a0e65da035db3495f8186cc2f6ea84e283a5771cebb9f8b44fe591ca981d1148252d292131e7ee2828152e8831e276670676ca1e07921f1446db9a2093c8e448259447de61dc1102bb22a767821d81f05f74d0bced6f69454045726d9852f526b217474c697a3c8599950ae3bfaf162650128e9a849997687f3cff2178b3f3d0af7a0230f8fc9b2344dfedcabea974b7c2dc2ff73c6d13af7413ff2e96cc64155ac46ca79178b1bb18ff13b320671195ac255b910b1ef690957185d90e7728e99367ae194a8cdbae26532cd07687318bf6bfbbbea49e0055c73c6cea0deb5512224fc31f6ebce57051adb67f962fba4385cb4c309c8a943272311abdc6b37ecf139a8520aff85e8d5b54e436b1e9c21fab511dc5a112e12ee33f4783960a8b84579e54d0494f7a801bc50ce1a7e17ca455eee16d696451ad3653e6001b91f2a70f97b66169f130472ed748a3dccdb42613656245efd20d83157f52037f60fa34131fce312f44bec2119bdc756f630007db2f6893e8de5f81d34e5adbc62e44829f4ae51f16ccc2e7b9fb80a65aee4de8454700e5cdfe5cb72a3648ec43ae4bd1e1a9faebd7b2dd09d9c75681ba1f7091b5fbe1ebc3b96e5245a7617837b3e0e13bc8412d731c60df6e491adbb0dc3b579100c8cdcc33272b7a793e14b95d3c84ba15ed83346cf6ed2c697c90b6f942dbc033646f071b6a48540ab58c0a6010488a0b8b9765c7cc52bcd117477cec7661f72989eab9673e624e9be6fb246a51eba44f48effb91f2efb3ca838e5649755e027706c90453f67a5de358406ac67d3e29353ec766f849cfd8b510e6818dfe4d6f6c2afde3a560d220fd135169a569030ac18c5426bcbf6a7c35ec0d4322422312efe4d32484311b8663f4a0a38765d9ae6156347dd33d1e4455dab0989aa8cecc5f188819c75070a210767b1d2cdc185ad8cec1f1b7fc2cbb5d418787d7979a8e83f683e2655f618e13f07fdf0d34d0321edaa15268d00711782395eabe53a623532116a864ae0e8fd7bf6b7cd22b3ab1534a197524d12a29aa36485620119cfd47565697cbe0f5e401db9b59985359e332726015942d76ad91784244261e6c96693dd68844ad71954af727b22ec83ea971e3126f00c9807b32e1852ba1ae5cef3e85e57faf0866a48fee68309a0045ba8ae78a0866d09a8473a7868047f94359a5dd9c8e5773ace99533a46bd1f40454486650d3b8a5c0faf3c14eea51112208354c324cf40e73839bb9f78b2ba2b64c0ed526c71678e56cf0272d104ec097a2cda1528bb2778407f523a1314417a9bde9ce83d9c3be5c01c393547d2cfefb8070a41b0d9dce4c87d15080e3d3db9ae6e832dd64914afd3eed5028389ce979e25535a4660f372a9d06ff535e9a6b47688a7a3c65518a31376913393efd3fc3cc566a2505bc726507a75fc0cbafb62a04de456e9e268f732416804647f20b446a24a0172fbc3c90beb59aabad303ac011cc5782a8f0b1072e59fb96875783615d72a027b580102159f5b44a170144b15817666fab5e92f12559ea539c733c155641ce44cf5fffc0f574c111ded65855ba47af42b04483e84ffeb973cac82dc6f52110b0a9bf756e3d15299a214cbd1cbe4616bfaa7b4845b882f0e7a974a0dbc4e76ad576d42ffc5af8440b572f6ce70e872540494d1e1a2682fac3bdb92c52ada8559d63b78940620a7f21e2d44d24674f6c997be58b21f154f6e3dcf0f6e9852206848cf30840134f0eb578bcd3253b1674368c046f6423cb83c96443f4288631aa009baafcb39b12b6ef10cc9ed57651ee6bff76f4918935a059193982c713f5d49795a9bb00eabec404f94c3a9ae6e0265d42556db0e7bcb209c98f254ae6f067344b4e3b8588ca9d203839f0f47a4009a0aebe71e5682a8f240964347092b5a6b52ec935c04839a28a52720edadfb590ba73505ae08acfb4b99888c0de5ef6e44831f353f4e5add9a942c475052a68f643c40a7115ddb6d176ebda45587019dab44e13c47c9e412c9becdbdc6a9c55df043d82102ef8462c2f1b6b714fff89e0643ecb7cc8ecf9c36bea49736bffbb7687263479aed2a23bc83fadc592a6e949d70dae6fcb757ff8b373a2d43a03098adde53356380be584692d1aa8985ca32e1239a05f2b815fc208564468e29b79d5056bba880d890e184e76a7f0ea47777e12a1871897d7b4b066daab78c3c6f5a7ed4cfd06fa0521f6532ec2f715d5c561efd11aadb390a1fa4188a30a4b2c0a42471fe6c8002f330f5d9e5e056ab43858ebdc51a4ce45339ff9400aa7339614337945b266094c3b4af4894055259688e670fcd0f960e0a92606527c1dba97ef3ffc2554fbca8a365005d57083d42d1a37ef42bac1ff552310051e26e43a94e3e795c23d16241d41c30bfa3f322399788c5b3788b81f259f5b357ddde6e5101a45c4a39e401f03222a4789b997a841ca68335402f8b2546c1953b3109ce415cd8e9b80bd27d6525552084761012131e0ab775fb532f178bc4bedb911a5b52a57c54af80a40f72346d8564a6e2b4b2664c557a37edbbe3da2fb359f7cba2abac98ca417fb109e815867e243aba61987ccc923e96c6ed68060bf539a36078552a03e4d312782bf8b8e0e5f9afb905daa1863be7fd592a471d8319a87156e45404e0c78b78da11da4623a3eab3a82ffa78adb2cf5e4cb649f342a7e161639c2abdfc9cd1d3b72dbc17e31c17ccc9bec3455e277ca5e4b41fb1705dbaa3d92313fd650421a5273ec0f2f2e0945a523c050a32a74e44585ff7f1152e8c2e01759e634e78d8394b487391aa8315a62b1ffdabcf711ba996b582a814763b2e9dac6e95dd2537b3800c50baf2d31e5c14d41f3620982c641935dc0178ae579bc14f6b06315e38d59c53a093d59b5bbf4e21b8bf496e6e775b0488a825a5ddb0dc511cd8c3464a6350fbea36e6d87cc9223216164d4ca7653c98e83072d6880e827994a5f6f79f4f84119b33cbf1fb378c799d7bd51bdb524fa3965f7c10a5dc6be94d9c5f220834cba2314ea2729cf2f5e6d27678f30b57fb8b81a62d5238ba49032b549228ee1465242af2f112b4cae3c00b829980e67cfcdd714964eb054efeb4d94f18c60c0eaa7ec14960cf8de5c0e63d488afbe948735197f898b02495b85a247b3de7ce9917dece2f54c0cb5b6886c9e197e25a994cf0e2f1f2933e3d82d0f2422fa5d138bf0db7b92fe2b748c0cc6d037068695d6d09fa672811503e6dda4b140d175ac84444a6ca80d29729bfdaeb00e504fdcaaac8f2c95af3da15f871f48fe22c5f0ffb76d0e927b5e9dcd8febe427cbea3839bf8f39edf2acf2e0894e96ecac5471784367d260ef46e044cd64f451834d8b3d38d7e9eb2e63d2f7dce91a85415398b3d97c3fe3159ee0506ebc6d5ab651ff7439f1c13bca0765dd8b31c58821cc65230d4886721752665f4092fe633146434d09cdd04a1142ea1f42c690a8a2a673aae529de6cc2729a36e71d5d5e36c7dc8a47950b93cb64255e1e789ddb79c26a0917bbe7f1de79e450aff5e08ece0ee7b163de2b648117a0f643ad1e9e4e2d5c513703f49496c8bbfab6cf9c62e23b4c4c415a42adc076d262b6f00afde24837ba6ab1254dea36535ea59e8cd4e60be07d7816e5ef2483a000d196efd89c64d81cdad7edc0182ab738eebcef4d5bbc4e91ea0b7105f382c7560e1fab052dadfa594dcf30afa9635979da60aeb2698c52e85715ca801c190a851a36effa9b83bdf049f7fdcfad233058a4ba2bb3a83ec1ea579557bda2fdefcb5e3443c4b389aa8a01dbba5a4042d6e86428bfc5104255a9dc7e61963112cb4eb225486b557c10eff910d8e13ac67215b9f51d43b4d8bd1ff00f2d6263b5e19557f0a55ef86cb2ea0336905b457d22030fd3ca2b8e8790f1ab03bf31908a897f524d0379769395eead379b166ca8a8e02b4c37ecde0051a1990af51d83d325389cd412dc8c6ba77c6c728b89c0e99d0b87f493cc09e72407f42d125bc74a65c3383e4500eae7db5d6684e9256abcc0bc883c5756cd74678ab055f24ec593223bb360b4cd797fbe9da85fc74e12b5b3d2c5b8ddb4ab02d24d7e46e1fb5a29d48b3b22f9aafd328678e2b3c3427b3ccd20b040005d8615607ee8d68da27bf039643fd8c45e7c831ca45498284f2598b24520fba22be4c630cd24bfae026afac062d0fb02ec354d3fccaf84f273f0c51d17e6b9d1b650dbb6a0874f28dbad4a302bb25a49f66b44712a5a22ee4c5296095cecaa665e64c6eb77fd0013b81c78a66c42b1e7b78df20cfda59cd29f7ad62004ec2011d386681a534a29a8f70c11027a6cf103628d24a2ec3051dc7036de500d071edb47a4e37ae7b9f56dc5081df9bbf9134236b3ae152808e115a08b976b302fe70aeea7bcfb4395498d734d5c1352efd89b5f154535d5a7a49a4e32c3d4ca3723b576bc4e7832bf4464777ba6754b10d8670ec8f31745332ea59b1dc3dc990b31f3b0d18103ebb6a757cfab5e9f9b3dd3c73082476695aab1fe701bc4068df575d4b0bb315d79126bfecc785b068d53e43549dc4ab86f01381a1d2d4d7153bdfe8148352bb47855358dd9a4d78083f16a61ee29041cb9c718b214dc27a39c03eac354a1771f3c16c87ce3dfe47a9c5852cae4e65c631ca9ed05ba624fc0f3ad59086ad2fc9c40ac067da063437f61478147f322e431c024cbd5faa04ad1efdcf2b6290382f4a6655e8344ffbe0b23244ea376d366ae528d029a24771a019f2c93c997a83952aa4b6266b90b02bca7b49ef1856633ec79c0caff44388bee42caefab38f8b570cb4265a8b662d87450549c3b3ced37d376ecc1a053be33bc9792f40c89940a2f72ae418c4ee6bed7f92d16f2be8d22158a4ab07e781374a50267e451396f054d6e2c58d2a98ea2b753023d296fd4fdf6706f43bfb5dcfbb26e1ae6abe23e4c0d610e19ccc600e20d2fb1fdc4abb23f2ea4b11af1ec89bb713d9393e27bf69834507151ecb5eae248fcaac9ae52b98bd9fcc204cbcf1ccfb0104e5a89e6023f8d95e556e1e9bee5b0eeeeb0257d3fed587b4a27fec08c444ab650190e8336b4bd3c2a58d507f796dbb40e4108f85fb4085c2b9ce9738c0fb482249ebfba0b92af166a03963ba4053deb67e6b5332bf34b0fa321078c440c5855bcac747ad4fe4e506125cbc94086a7d3f024b69ee2c60f0c36c1d12af3b5b0ef25700449f16d633f7afe6d7d8f93c8c6a5cbfd3b663f82be860db4bd594306b29cf0b4e7c2025b6beb19a62c9aa8ba0640283e231584e7f7cdc6c30f07efc587988ae11eec7adbd65ca5c6eed094c62a83031d8c57fdaa34b192cd2fb505149201e400198cc96f9f035a7d21995c9c7a80526cc193f425b7badb7788cc0717720d34cc9de14edd18c66c961038f0341b1b577cab2627ba087b1671bcca177b608239dd265afe777b49d8cfdb72b0f946231d534e16f14f485af58505ee558c7ac4c6449b75961f2b2808fb99020bb48b62f86e355712b63ab1dfa9c88b08c12c77a8fdbea34169ce26914410a9be314e6fd021c21c7084882064a00ced5051304874b66a430b8105fae6b549099c7e70b469e80658460efb8c12f12a279343e07fccfbb8f4cf9509aa6f05ec98d4cd026bcba497fc02b9cad6cfe38dc8d9f45eea06a7e19ab15f68490862ff7d8b7671f49a871844bcd56be49553838a85bd30d319c199a1c7010e294a638208572ea98ae6171859c554ac45e8b9b59c68aafdf1592bcd70c4afe952267e75fcbacb1ad54c476c59a0a1f165b0851d0a903841324d48683e11127b6e6ba802433d8aecb8bb9ec125aa4704bd142f66c95078e1d87da1dafd655bdf734633b55264a37ede102e0d63775817f6480135de063441723538aa6fb26e83c271d5f25cf9ec8ad3b773ef555df64b0466cf243350860b260aad4fd74ac9ba3bf5c5a8af0c44b264c7221b5e5c1badcd364b50baacd382b4ef82cc635a9f38b71bad4baa54acffc7c33538bd3a658b692a5a50f8b61592a01ef4b365a6db07e460fee5637330fff1d20ac4fb26774726972522f12603a3a51b6e9c66edd4b8ec2aaff25a0a484c3b626ba0ef294b1b55e1e7fe85be5433b181f40542c380fc16fc77f1b12b9f87239a9f4e1b0234f76d0321f4eee0399d9e26aafbb779d11c897fd86950116ff05f39d66c05a7a15cffb86b8b0bdddc32962af0f0e9b5fee77ed04904a1e02e2a50e670375af652f6fc0439c4b9050dd942e6652c841a4c48dc45c0c41c26683f0c92cdc1610aa47531c7e92c9f4158b4f186ac2d0ee3ada66f90b2a1b72417e46e85f36cfb4c959eb313258c4ebd656b7b42661eb7b67b2d6ede6f9e5258d4b838e97d2436f5c830ca5209ded5745ee87b71c5b8ed8f6c5f6711f5e20fb3ce375e573a91acbf17e77cced2ab0ffd73896772f82cc7e16b8bcdad8485d7514715fd44139285ea36eb97f30b6e1028ccad785791c8eeb93b49875f899cab82d271d90e392df486d30b8c304cbe13c95b45d45106499e2e99ebab09f61c496d25e4b4b610415c16488ff700f78210a86c5b3c01e98cd2704e1ed9af8da2e2e51c793c0b9fe6fdba0c62ed3c313ac8cfdcc24814c744aa5fabdb59e38856d9118ff02daad3233a1c18feddf991372bdf01307f85efd1f954123e67602120be62421abc16b982c25c28476d99d86c15cffb62288aa2da2ef8d1f51937d75acd8c2a11b7af0900209cfb322715197a32bdeb60182934277a96754bb46d41c91beb60a00bc7aa6d450ef355a896d5e120da9be1333f598e74e490fba46c41fa9f96c9f08df61ff79057a265a9b25e007c3cb3ab413772fe07c58d29d2f013310b94de4e9a2c0749caa988dadbea6d6ea881b55048d6b6cb3c882a6d2c6c4bcf15c4bb647780814110f65a8f8f85a986a3e842ccdb5806da27b746bda49e21d741da47f83e81e84fedfcd5595896b0ce70d456fa5f38b5c6759306975f62ab871970a2cb1d383ca7a92fe9f56e79d67ec98707f42b40bf6ddbef9d142e8cadbe1d4236ab11cf0851ac7470e690a768f40fad82343aae86d7427150665f60fc05b8b484f323401019edb3875ae0e1e0aa7b0cdc0b4154f7fb34bddd833d6e637ad189683f84dd4f665cb51df41d9f1269b108eb3d1113fc97c2b2c9de28f2b5fac3e8ccc3016ffa1cbf4e73fd227f846423d83b067236da1352a894561b088b1789183f7e682e0719403f1b141b9c0b15200cb20b81c36b553e23dafdc9e24957d163bc7cee5f659aeff74795076b4dce7bd2328159afe629a4c48dbe88643342c1bb3bc1469365b3e47c57cf0940ec351c3fd63aae21e4f4fb69d49c13cc352503d95170aee350d0730743199526be00f6f69065d6e3b334772754c2db73ff4087f00ccb0a63e3679e87096451e67570d8a2e07a48af73439962210cc3a36b0a04f1459ffc815a6e64deb5f978fb9d864732b63300b87da43223021cba811881e63302421205dc9f6f91e504aaf1ce0cf458593ae5e28961b52af97d5e7d90eb53930ce1bca91ffd6f7a70c51f9c5b255de02ea4c9cff492ee281c2b3b83561b465b43705f853f6d66b5721204afb75a5bf940e959c8c5817b4fce93dfbc81ed07fd2549736fe52d75709db733ccb61058277773f95de1f892b9e31887604da371cff3bee8e67b7b0dcdbe5705c793b7f4687c7732556071434263f685d424e8270d9f32097021d89dbdc296228bd5611bd08a7192d1dd3dccf7842a9cc71e532e23bb106687e2f780cd8bcbc661531b9d23cbd806294d551facbfd2b8a9eee79ab16c080511d581c6f739aecb43983ce41224a48426bfa889cd357dc4bb631311175e8651664519b63a8b7f32d59a016f7197852e537cf8cf5ebcd089b322af92046fd6257471fd3254e9599df15562080d99e862474fc59eb08d1b1f78385691ce64cd561423572132b09bc0afaa46e7e0562b23ed529adad349a67c6ea5b2b220231489284c3ea89d49f28e509ec1becd47ef71ff4c8aec8cf8ac38f70a39d7f476b95869fad88836e622b61c9d6f9dacab40eb664f5ebe252cbccc85f9a14672fa135dfad6a50a92ab88b76af677955f7fbb7d0131cc7e5b80161aed18315cc0d9b1e65ae00f14edf72dbf5ab90cc1f1973fe8b1f98e0ecb8678d956ac87d9d335eabbfd7a9fb8b2c96ff7966bee7954f724f49ccd85e35bcc2c5646bed8891d1a978adebc112dc91d89bb9ae724fb4f07e8a8bed41f58e55067731484a5b1ca38f1bfcd47f2ba849d7ee385b7e62f1cce05a15afeb372fa8c96ab765ebbcc32d02cfd49456c9f0aefc3779733e9592d5dcb22e5b3379ef59647fae6ff203b2ef754d43ae39f1c87fba7b0f6163c707a3d22bf3ec6f5abbd28d0f2c54dcb7aee33d47c97bb4451976a9d71abb7fb1a490e3e91d7dec2f382d340b941733642fb8fee432a60643cb8914102f2215a765fd40927e76b8dae56b8d86c440d416dd7c2e632826da8f390d2a37d4ae2e0f23f1626253729bc6028e2de56d3915f01629d6c12f885c47e9f44c650e8c0266794b091db03e810f8aef01933a57e7b5206ba9a8f59f4050eb978179b4accd0ca99f26c07359ed8da074016484718286edcf9f58ed0bbc896c97af94efdd987eea9cfcf199a8914caac4c502c778d7082a0b32b5f4b8d8c7def1959b6ff7e990bdb77f5fac9203ee3dc420b2a2787e2bc031439fe781439b15e4c34639d79d568f751131a31e4da281970dda6023145774c28afc6c784321f5f9b7da1ec663af22d27ffb05ff0a4dfe2384cc4900de851cbbd444ac98621603e01572a8eb5f9881611b0665547499a9faecf77b22a9f06bb3b171fea27d344bd89a0ce9902181083625ed3bc2ac2f43b94d64396a2ab38014fc77eb40c1a0a67bd33fc8682f18093b8bfeab9255e2f79edce34be6b10ed6405267f32ff53ff071e0b2485b82f0539eeb126e9e45206592762a6c1cf6ece6f3be650e4c6ad2b4b1f172c5cb25cbde65ebfc7826eda6a72bb5917c7418f13c05361dc976e4cf86cb5268663c990cacc602ec1cf970818236db09704a9f860228be506fde1e4808ae3959c7193e98d447d9a23f4046c1dffbdd5a5764ab7f09edffd4ddde29d378b3e5f032f33e84fc5b8e7d54b0994b5fd92ee18525a25559cd3dc2703e074accd04afda53ed1d9b96d8bbd7e8ce473b1da2501481547d9701dfb4dc4ba3c16aceb46ebd0ff85a02ac11d0765f0a6fc0913b6deefee9e85ab67498fea47a79d2c555f7c320d95166d0db4f0350de846c9b5f4fac64d4efce477b818ae539cd65cd048598970b50f3b9dd50f2d1fb62b41d0e63d73f8316c398c14e37b90a953a35a1f3789418d38c24a02d9e01189463e20953af77681c442484b9b5e78ba9904a485c7aec4eb32d2d103c40de9a031f00f8ec6afde2d1eaf6f140427759c4e206459e336151915d8a7f3def9b6ee311b9ff85fcf6806e020d69d390ce0954bcf8637d82f12c8fa9340c897c5902f54e960893a4d09f5de0b24d21f0812934731d2f463456980560705c3bdd6808da6e5bc52681e813d2e86f0b21212a6fa75d66e90315413d943c23b61a9f1ab36c3ebc0ee30f27b85975cb35e6747ba92db0817aae8dc7f9bed927e356284cd006e905a3d3097e46b1bd7f87f5a8a471784427063eaa70e866b576cef322a583f04ce3c8d29726abb4f9e01e15e1005abfd65740f9cc1c60a4442fa28880a240abeaf19dadb860b3b524cd139225a41fec16099ddbb8dabf2fb981f473b006b0afb3a8e25d5ff17fa6b24389b73af5c16fd79f9925e2cb04b34817694bca8cd0f10db89ad8f20fdcc9a04729e1814d390a1ae17311765e8b509a1a693b60a8d93b8cee629b84e133cc448df5b75e020e40c35d90f4c7d6b093029e274d6e09239035f0966e02bc2f265d96a2e127690c38c5bdf833bf6214b5e240c74b0e4011214f4414ac5898a3ff186e9645abf69b70061c79d0beb8382a8f4451563e9c6d4541656a118a2e1477175b5b3db7d8a10eb1b1530e641a81ea3857432b2ffdae18312e889505438cae7e6dcb8779345a4be6bd101ee3fd78cfcefb8f6fdea84dfe5d3e523f14fd50a3dec85d56ac9e75d6ce222c703bb64a7211262e306f52e603f4ef11dad7aee83de551116b11f3348d962edf874f92c8efef0c5860ed6583acaf41f26f831da72b429bc5c6edb7a7c7902851e067ed930a1f76ccbb2a0177f71c17677f266bc7d8c991b4e680aeead08e4b3447d703dc1bfd84cdbcada8a8a195bf5c29e8cdb2c99d00df1c64b53ad05bab34b140429476bc76307fcb044df1e5802196f7b6a0796b6dd57f4d748e81afabd558a0881588543efa50d949419867d1dbfde57be7b3d30b9b6fd9944c3c065be86e8ed021880b96d86e4226dfc579c005a6e3c611193cf610d35fe08bc53f00df22292bf821ebc688928e4a7bad886ed4ad237cf3418669084fb377468a47f67266d68d6d81c758eef36f2db8d17fad91d7b0f389ee09e6a600f626cc7ff78501013ac91fee882a768d837a7be0cbd061d2224d1431e68928676a22b981e60279bc0f4c5d856f8ab7c234ce61f41e0c6940d8efd39a01a1e055ac3d2221f7428072d05a2c747e20a82f9e9fef7bb71f2edd437c04c5e81fc9e75809c9bd556c231755d6d8bec59c6b9ba0954486d16b1ff04e086f2920ac43af917516a53559e43c8e60f58300cfa07ef71f217c271e1ed1a37dc20591461d4a5878e0293366dc68f6d19900128dd0f2f2939c4cd02d53a10297a84b0e34a0b337814f0ebbe0c5acdfb6f4f5780ae0beeb2d4d5e32ac51be81b70b836b4f0ea34fc835fe0cda20284a3e2857e6649cea5b85de1cf0d683d7a2f164fc0e6b5b701a7dd4d6082ecbd76b077ca78dfe366d312be13546384ed8b5e2a5535563eda0e0cf9c9fe3e74f4b9ea06c5f9fe03bfc8e7a954b4ad58183a23789746e5a1ea5d5009fa572c124ec129dcee5ab7c372cfc6316c8506db33ebf33fa5dfe811b0d9e6c7f240d23efcc04ea32db3bdbb4f38caaa7a31cfa2853e9ba10c176463b58cc1f1f83d266821aad0211d517b04b4f79f631ede3e78cf0b1842bab748d9342dca3ef1365a1c1957554d1314ad834fbdcdb6868790d2f1a0b24ed03075dc0f1c3907b2f2c5c0b3006b846e0883dbb66e6d5d651c3cf91347fd50374cd1d16f9cfa47d5392d983af3cd15526ccfdfc98cdf71dd81af18e8d82c60b024762745cfc7273fc827e5a3a098663d1843cdbc54c20cb509f768e3062e6421ab7df506db55dbc322f68d6e3d15c19dc2a6e9b45b63bbf58ee850587ddbde2f1c0fa3c0dcd1d34cb67388cdbd3131ad601dbba6ea7890ec905e3b7131b1fae598f5d94cdd5adb2f01f1fe82e4b960616ccb369b7692ee51fb78f3ee9676e37ef0abb5d7d8d26f3ff9f87c30f46a7832af17fa1ede6f137ab424641e0daad42552bd16e3491e920af02956df69da606297f0b02ec54671d2d158651d962c7b40251be8fde99a8542a41c8b318670631e11a90f00ee58e11594331d09355b957acee39f9616d4cf00aaa3fbca13311692cf4cdb0b58ed05a4a988961f4aed5d88e9de7415e2831afe57477c4e419cfae31c4e566de67825e0837dff261e43db941fb008e9116e8aba2b21c6908430ab79af503d35aaec914ca7dc7fa44e25e4b44df4048a776d1b56b1bec25bf251ab0480a552f1a4d79554766d2b7fa872cddc6483bf3603c0915fcee493d01594ad1f33649e711c1d61200142d4bddaeb3cf12f36b7b8ca83ab759647b7f38dfc04acd780723323b5a80f8363234dd90c6181a883063c927a263f2ffff012870d4b5faf10ba85a389211d0ead7874eb20647c3696ec0cb8f2225089c25a73bdb9a74b562f392dd0d31378953740df09deda48138f4df8113b379bde286517090ac2f59dd868caa7deed8acfe2a15de01e0778476c6a52e55eebbd192431e9b495242e1728e3e0e1b68e1b755c93f3dabc283cae345825aa1f441821e3465ea932c25861b88066bfd77e9cee803240716f72b2d3c6c8c8296ada7813d085e8b9c2a1d567de9592eaa94d8eab8a4565ecfe1a3114d167db005e99ec385d553e6822953b20d0d1849028337ebe16016a250850b4e39d63d4e7d25e86ca2f1232a168ecb2e00108a90afa0eaa0e13ff1da82c713e66fd42d8b580e4fc42454f5b7d9cfcff7f08797a4efe9bf9ac9a25b47fa4a4af60dad8b77529a372f178a5d126aa452b266dcd472dde2a7f9af00e9715a8605d83f14869d80dd7fb1a8c09f2cdf986466df224f84b135111d15e6911ab049136382fd067a12e64fb7ffceb4b48c205b1108883bac8338826bdb75e5c2c64e37b790bd3177efae64c4824535316713867c2fbd1d42cb42e95e15c409d93c7e92fd768c21cab638696d704cd7251f32ddcff886f32ae69641f0559e456314c17b518fd6dc5e05bbc6ee7768cf4a46dc78c4f6c06401758cc2fa2322ca4f2c3c3e8f620bbc9acf0780d8bea7fb97f033168ea402811cc27f7284754509b89dd2723e9b13a6dc910da1d64cc7d73731fd1ba9eb8e6699d5c729533e4c0c310ea67fa9aca3f55aa5db092540239ccc95bc17d1a5cd15356b2ab055815926849faf501bec2883a7e34ee805d6ba5ee25f02bda68932fc89189402294f0f8abea054e2188e12e90c47ea4b112347fb552009220f8b115906c306210cf5c1db29a7bf147541525ce10a47f433e35cf72896a72a70d4661564ae0c7c5214f9ae5be66c4e8f74435cdb52ff1dbe761799eb2f7ccf50022b9fd3826d4ee9eb57f0d18e9bb191b9254018dea439b8087fbc36fd02367c27ba3970fd254013cbb10c7724ee4d19eb6d3331afc0e0839a24ceca184d53859fdc5cbce78d2928a2d841f0c75e065523b33cea173c03e3d96a2c81aace29bf97b7bc127597752596859843bb9eea0f8e8bf58f39aa521510384e09c4fea44d9b9b1bbf621d5b5eaf90874f9a94f4ba0860c32c95f38f0b9443558ad89642efd67681a3f535bfb2a70122e9e8537fe0d9d835c821337db28192ad6e5af1eb38fdce5ea5ab136633a835c812d663a932fb71d9d3253eeeaed90d7507edc676bb2ca94807de1da2d34b76531cf0f9093d51af4bef33dcca9d1918c165dba6dd9bcb65c8ffefcd73f8c01f3a3054855608f726066c8655fbb79266d7945eb8578c44c8aba6d8e8ef576f2289d87ee9c0ac8a063fabc7052ec200f57db8dad45d145500f59c75703269587d807b3726622b4d9b00f276ff51d429fb96be0fe8dc5d90813c7e95988eb8c0879545f90babbefd5b47d3c9781bc59fa87f0cadcb00b4438bd5c223471e48086cd5cd6afd46d79db2b139261ab4481c0b8902d9cc90d36da52f03041ee4eb91a4fd6e8728abbe613983cc41b7d4d811a07b5ee36966e83ceef90b551f22ad9b0ccb0dfa3053e933f9b9576c6290176caccf568970e82e5f14fa9f6cb726ac11df038b9debd76afcc8c21bee74f4316d66fcea9c262e374e1af10bfc99085750ee1ddc6b413f27fe5bb08bbfb23336cb0e813fa6f04a9de709a67b89d9891d175c39e8fe2673fc1732b4e8ec4ca228d9d68aa628e795429f9a8fd57da39c81d2947286030715c6050455b8400ad430a94944901d0bef45aaadf2d7e04ab7e17a57e70b6c04d996c7d763da5d2bf6ed8b15c81252dc757d976b24355d8420f715cdfb7401024b91522ffa14b674c25f57c7c83778c2b74d958de847cdfcf19ee8ba8807d8cd33d2c502bd86e15a6ebb631d1e01ebb6753f5dcb35ad917e0ceb1db30ed15a29139f1c24be59067b412baf1e36f5a2dfd2c72cbbf23842d214854e70aa0f7a9704a33a349a6a2e16a3f54a8ecbe31aad96c0412a6aa359c797bcc622411420bd4080fa4488348ab1a2e1f7e8c64a46fda6b1f473ea3ccd47bf7cd76e79d390c944f5c57eef930782399bb305629d1833f1d63298468f1ab93c5e4b364a8cdb8789a5e18b3de8090bb9d2a69be41eb01c16a3fc026cc441e183a8e61f8e21344f8ddbe02e6554b2b4f496a1d32b00263bd38cfcc3e0015aa377037bd91bfa4395c97b0932abfdf3171eb624714873e00fbdb62033de47817b1ecc85522e030d380e17458f8e8720f38a82ede21cd044a72e1cf8c90c68ba467ce916b6472a6b1c81ee34bce2ee50e8b4ec80387427cb4f5c401af16b90a60cadc27b4e72d7a4e83f6ad98573cfa75215c4bfb241dc06e82ab0dd1ba9ceb81daf9ee51e5a00293ff24d615db06034b952264593fbbb56c91a00846b6e9dd412f1a7142fc9c83e34062a500f420f9310ad46dab0233d6f8e44ef446849ce6810a23541e8d39cec0afbef33bc54420a9e201c6e5419f6353babdda32dfe5574a9b664d0e5a56ec71de74cdb854f0e49b56eb481d544628287f903428165a96207d9b894d1dd15da5aed20e4051be2ed83907b280dbcb8a0f74e91d1a8dba4b94405dd4c4b1107a0d230b71085935237c74439229d081dabaef609319dc4c143cff03dd455c34046ede9468efd240b949f3aeb329c2bbf93d123673c1321ae4700cbcda9255059a54c5e4488a5b7d8a7b2f9552779ac437a970116e6f6a6a85c2ed58fd354c75e4feebbda2c5a3883ddfb5bffb9cb1ed4ef5b97b77985b1b1ebe7e3978e7d8ef9bcac7caa9007ebb80e94d0d48c8df9b4b513fa3d1059ea89564b95306f1ef40c2ee5b39d69194a24e3a9c1cf11c5df22c0191a65f320ac04c4d4b38b59f24299f73badbe8796bc1de850ca97fc86c61a18442cb6bff5472d8464d082691d05f829b6c4e2f951c73f2290502b990e29d8623d107c9d83231d9ac073b4fb8bea724ec00cabe1f2e3a0e74c9f87711c7b688ba82b6cd84a2b8c4389ca92e9795db7a73570266a750fa6798a1487a7f09313ef1e6fbad7d68431722aad8af5dcffba13b5b3bc96dcdd30e791125338551d4d5a93ee11601e6f327f15f406ee475d508c8df00a2c115b9b8db0e96a6598d35a3b093f654488f753e9ce5a9773da9cbff9336e81d7ae555b9fffe067c180ae03a9c539fc44d0bc53bd0014b4243339a03008d0aa6b61c7c0046b7dfb5c00dd92e2dae8b243face23696a3217c912aadf80b00422a6dce27057812c998430676987942d02e868d02538193780c9e5105e768f2a423b816d88f4aec26328de062679a72d3a973ece7705315d2e8b4e54ac364a7f9eeb288b3d399f5b371fd427e192165b72b7a430444493b3b50accd56b129c4bad77bd1db4a3ad36e7ed88721a067c4997753ac2b19de259bbdaf7de7a2d3d7a87ff6960ecda168981b5323726db974a6071c068db739721ac221459abcbc90636bb38aeced5155b7e634a30b2ae6ac171e1a2dccfddc0f1519bba2c770ff28e869657261f0019dc48f071fbee32a0e4f037b20b8f768f4c4c735c09ad670e38c686744d9b7b523029c63c905e219fa71c817f6e136de34e0f454a32e5d1ad3b822046028483b97bc1a272a094a120f0e88679bda5eb181b972c30155e790a6f934011973910526aea09c1169f182635ffd228cf4419e02fdb686e99a6c2482a6a3894f49560ef95607aac0f8b1b3e221f155b52912772bd6dca657ca5420ac8925e93ef500edb61d00d2ff28e84e8d6fe40fac1aadf7e10d922b921dc233fc2b41c202ff257ca03cd0412126ca4dba1cb4d7fe84a63ddc44b15f72b48a463c73d1f3df62fd9562058c000d5197df5faace412adb27a23501d75ead2e70ec3f678c45ba9378422ee7fb86b68301cf5d945b3c643639e56242d0d9e0881d488383b006414f3fe85ad84c07eba781d132ae9c652da915b9777745c6dd3d10e35924f64fe16e4dafb78f4724825153b70ae9bb16d917962bdb7538990795b04321bbe5f62fab9f03c08fc8adcd271a6fb024ac651ad9d522090ae3e85848356d60fd41c6cda4a7e0a159411ac8be5d008ebe03f717cc43af318b5bfe61e2d317d8dd382e21abd716afcc78d831c1067cf3b41ea43d8fa380eda313649c4dee2742ef98c454d962a213a69c216b536b71cda4c920a51747fbc53a99fb5eacbf1417376c342dc9e524fb8f6303e0221c691bf1923f8c3cabbd93596dcf26a62f1aec46f68fae42f5209ca6f92d13f4ce5b3848fc2e6102c1d61c7aecc31875e2d23108cb7b0f5c4b4a540d4389ca47d141ce8f746e7414bb018ce0ae5159c92b32f711255e5e79ffc8117da9f7d9d522b1a14d6de8ad493220d173ebd047021529351ff048a600d907a3deff0afd18ecf647ad9cc35f81d28714945e0d4eeb66364307fe3fb10b43db25c903f067aa14ad42c537aaa1ae93e3db7dc65546ab7d64e9615b471c5ec3b5053095402ba6a24ecb3525f5c12d8b549584de65cbe050d786a105b4b9ea9015165ef18d49bbfe0255aa750b76f2a0880a8e413f3bba9c132cbad4a656fe6b2760399980c7bfaab9ce325fcc884e6fc625e082aad932eef4244aba1b99299367dc9f1cd3f2a88bbbdd465aadd94e111af41f424256c4e35ddd8938a933e5584b75086f4229f09e50eaddc2d6db73b9fd00fda0975cff870f05e6e281e22ed083ef0aa60483a2c238aa0fb6ce74f2a0dc359530a931c2463e550a8ce8a39934a99fb45fe95dc39451619cd07007550f449e8b05f029da8d3f1caae18709af98259afe7582ac13b0575dc7048227b171095eb31f9f71149100517d9a3c164ba1e74aac7a36761240163b8d4017b2ded70d43161279ded70e20b6af96b47ddba5fa9a72bdf70b220289f4d2dd64c5eeb14cff61e7a7a85586376c3126780f841ffcbab5d596df07b9c017e19d917c14654649c6fbc04701f04a03cc6d119b73410f2ed46542f37211a67462351b82f82e577e96e0f795be5d6c91e97e61eb1130360a0a1e76cf39e2a85fc77a52dc94893fbfdf1bdb08808769830acadd2376eb929f02419018eb3970bc5c453ed7c678b893a424dfab35a716d35582bbe8f516fe3a90a415fece7630af9896f868b5b6c5a49a98dc9e473940ecfa01b012c4f9efa5c06e6eedb1643f461406a7b292ccf2141e34ae188d1572e4c17d18a6b8a25a07c3efd234f603d9ee17107ac6efab7ea9e036e7c907426bebf62db086ab16b41edaca27910b3b1c436020605bf1c250244779c4139496b181d640bd8b950bf18649488ec63daabe86f6416df3927aea53b014505dd206073fbec885bb1bb048f6e65c6561215f91107d4cd28dda53fc47a05263dc9abee9b27b24cbca27055c2edb66fe7e87b311581fd93336afa0dee4c42952ef2b62891c0fc3c7e79eeb3d4684f3114bfdb395945044034bc80e2ec4a0c459be54f8fa9122f9090d947acae3aff090aede5f3534f390fc0df7a49a84810872c9bdfc9fa1c61108e7415565ccad063918972a5d23c604a5fa0861f5e411bfa217c0031731c8929831195150abf650c55b635f91f2e6461682d539830474477f9a7417998243612a03edf9e97a675bee2b5d1ffac175453fdb1e3f90362d556e9ac967b5f6b31906c560d6e3cd7c6291cbe562e5c9b225271c615adae524e233e0a5ee129cfb9846f301b56a6ad66ec99a05bfddf084e261044f089a27d12f77018fc5aa897a1f1e1c2af15c0849784d11b1b3bd46072b8ffd8fb813df393a1f8a7a873e12397fa7cb5cde2215a885b456911ae4cb7bc9a1e8abaa4bfeff35cf354aba417179efee999ff527d4a960b1a8585b2d6cb3447daab58a7e50a80fd9d59c7015731d3045c826f3bc00890a644c442b132c42615e4840727763b12f0c5ff7a48bb8d7c1bd5c320c1ada361bd1058a12795ba2f93a52a1c7034347d4c55248a8828c2a51cad287d632a42e9f1361fa5b13ebca0e068be36a012e5f214af2839777d937e38ef3fd456262ac8d342a20b91122aa190aaed432073da12fbbce820983fbb238f8d506fafdc6070d2695af82d2ddf84ced5688a1e6401eeb3d1846a84a59e74c22fda197ac3ca14464c73fba5447db2218fa4dd328e938c10e4597ff44375d40934714571894d7aaa19ead9fec29d330533ac83c52464278e4f5b24643d2857ac72e51d688e983926cdf29a6d6e3041e211f7f39526af3a7759dc9777c0a9218100408213117c932ee7526e6c5cd9e6c73ababc56c20f9632d405baea8b7156c9f0da0d4e5db6e793933ffd8da604639b07e83d74b239adf35a7c0a0d0b01e9e6955dbbf93197ce78ea39513ad98f2bc20e402a813cde1c6fece84dba1cf43ff784469e7d86da15057d44b5bc83c033106f016595acdb249cb63a106331172994d2c3bab5723cb4888387a38ace5ea04f17c6f058190b0198a1da308839ccbbf04acbf36489d18a6128d5662b49e153e436f2a95a5c9d5c47919de927214ebf35f0d9cbcea448e10da78b069289f15042a14b48ffd19d0823329f59274600536ddf4942cedd573e724fa73e1fd5c9cad330599718f1bb70c5ce0e92f5e0cc5e366680d31e51953c3546232b838d7e6ce0dd0cd92107445f8f3689116cc43228337c2222504c67d50e30c65b116db29647e9e3f992d3cdab83b2ee09083cf33f1a39cb21d2543ab04e67817fd1f379dbcc48353a0be1fd5b9fbe13f3cf4b473f699cfff88b3e39f4b98e2f68e066bfbe0d33f43e473cab5f15d576a432413b4b0b942aa71d205e1263a9b500211e90fa8c39af408d9d532622b71837bd722b0ef3aa251f9404d2277e36e510a0396ace7788120e88db41d4609eaddb14ae8ac05ae3fcb0d46a6a7ec103c3a7197f66ee82f41d36ede86c56b4834f700249251e35d2d78046698f76f029e5dfd9f8c3ddf269c233e7d99f6f644e78c7a0bb6060cf6e54cd1e696274748ab4705af3f9aba325e1cf15fb315ef8ad2e87e4583e4f34a90c58e3857e123553a71ec01621c0716c272ec0e2ce4ad64463729736fc34fbe3e8c687c1513d1a274f0c3ea056a451533d342385e508d223b919ef826b9db5e2fbe2d6ae9ae3aebb6a6c2b32db3af829733c4b461d730baf45dcc5e5b4c2bf4e50e057b3dfde3c6d83cf2def6a8a64c2dec6ea586e33ae7e066d2c245eb1da853740094bf8e603db0a5d0c8a1f19d21d2d7ea331c18cfc4bf5a94501edc5c0a8acc267ca38e068dd153c78be57653e5e7dc149d7bb47a2b4e6e594370c519b49960f1700ad74e992a63a6cb2f79612ce6f5b40174b2d2b4b95b6691baa251db30936042902ee9a465a0df92c7bb6883bbd10c279c474eb0b62efa12e0abf9318082e5eb60ba97678ce0e7ff188f7c4fb8fee08e847039387b41eb315062fcdd821b5a9a2abcc9fbfc0896eaac152eedf1a79cda57c399e8444821f508cee511c240719789c187c459492660899037c60baf69882097f55fabc93012483a16e28492b7c0963a3f27f4b45b029a18613cabd7eb5ed13ddb47a89dcec72a269e000ed5880c6cb1f16aedb781c002a038d1fbaff729b574bfbc848e304e968eb9bee40e43b9ef2dab4422a12f6cf09f139ebeb775d5518f434fc55ba589616d849c0482be2842863bdffa57189a90882d6357b6857afa4a134c10d630d0a116716e30b69c57c118adcb05b95aafa34ad41a5de87d4e66b5f4cef594c1aa57e6c936b450c637494c46f8f02bb4fdaa30f1210b4cb0fe9999e6786a737b4dce523c21b75321308a4be9bff7afbf009b68677f58ca96d8c5c152f54d9f1a8a5ffeacd3d5ad009f268ca329a9165776088e7469b7b981e06cdf224aa080a2aae3c896e951eab2994d0c2e7b10e55b52f8e0c61790bf056820767f7e9350566a8b7ccf7fd497f3d736ec1bb2e373a5349774632318740a19440f7f04a53400ce6fa736855540b4a9a6d3b2d1db86cf38326ce490c05710804e8ddc68eb882f5491c3d1c2813823b45b78d315aefc510e02f755165c89f9cd1c835e90bdbfa5cb4fe03023a95daf1ce76247f23b0291a418871b99f182282c4bd917a72ae04822330dddc76923b147ac427b38b112f6d04975074cb8377e24b227af17a2c96fc779a6956b74320129e3274d6297cb4094483e79cebbe83bbec889d08a5b42a5bc8b2faa551c7ea26c03a64ca2d8e7f0a84c63aac6ed1f5bc6587bfde29aee028e9268b7a551dd1aac504f8815335f06941455112a4412145bd7734171717cd7a19875895ff30fb21b603c214b519bf150cfe7b71f92d7f7a33323680cac9b8f56d3867a5434089fc3ff1cf286e49b368d61b5dd03c65a45f8b41f95724361c32cc9e0f285196c4f79920a414ce24304c0c4842260da32faf8e0ba9a5a3e1a76779953a70091bf4aedd223b1ed30dfbd223b8f8ec15481f19d23324479dd45118778beee05cf1d73d69d908f0d5d2a6b0ade560ca21bf4a38b4615e380e74b359059f347e9c13018c0cbb598c96bd5044aaa5df01cf65c9712126ec941b411a3f04b21a092277e1f37b24de7785c82734b7411dbed23caa8a720ea932bdca2c5aa6d1526e89c27ec9431a097d592090aae243b36ed0465c4572ad56899e58f8087f8eb6acb20e02254299addc3812e12656a278077cd7c607116662377cbf59bc47da1f8e49438125a375373f15494ace2b8b61d9987e90fb391f3c9d029177f3a6e56edc350441e7db4dced979b929fe1b20c82b98271e1efa2ed228245e3f41a1ff9a1442552bf1243b642fe020780eafb596f652b7ae516a2f2386b12c9833a8fd926a46d9342e9c02d3baf7d43b3c004773640699d71c20c97563aebe33caaf75d7154b28a863f009ea5ca71e8d074c881d075b0950f96accf38811a4ebfb1fd60fdefb48431b987725fc72e7a49f0002303647442f5dd1d2b785bfbfb5d0f5244585e5c16e6f8ab1684f028c7a7a4c561e9cd5bb9f1c554bda6f03be85557719f2e6be9b97b8537998e0d5ae910d04753150dcec2e01db6a38196ea252caa30d40d404b4c8d0e2aeaa1db6e2ac204f1b3ea512bff23525271a2bf6854783bbc12ead49c020695e945aba17affe19378b8e25b1ff59c048bbe8364cff2217586473f01bc10d005d605ccea5a55b7a11848b9a47a4d9687289612a385691d38e6c591e52211c2f48252dfd3d8630d5baae8156f098ae7f3c7e48798faccb746bb9f057de3687738c110be1439248a984cd5a6732f211b07949bdc049d4eb5db20a2a11e1d1e10e3a1c84cf1737063e4e21a555924be0cdb8607d843af08cbc850d31e82a54a30f61efe659119044062f6d9caa6b7a2d297900c8b67fdb760776c50ff919af727217bdc7689833f4464ae57847cebe01bd3d71ef4df964dbe7c216992ae80056a1b1002f590cd6290c384d4be8a004621132208ed95720580494c9570dfe3f6f5f2ccc27b6f7fcc3a3e951e77d761cb7bd1f5abb97ef87d6db28fdc9ff3d32456654476f41f32ba51da1e2196276629c701b21c4c8dd2db0ecf2475e1391da781196c1692e543ad93d0cd4a2d0024f41ace4b3d0fa6e559d3f2dbc87c0ad909c002e4b9d75b4d15d375ad35deb77c953544051cf42d7fa6ab801797b9eeac662207cc0fe06b878425b0c8350afd8ce2902eb15a35c58374fcb4667dfe66d9fddfd4b68f4268d5347a003ee03c81e1a767837c3435f5e258a24d53e0ade579df54f64f5923bb15221af319dab5c805d7da2bcf20ca6d4887058fcfaa87f8833ecc3ba26f41aa564e172ac3d670aad18af8ffdc46ddb78029260fcab5b4f4b3b4d4e0cc51c31381bd3df15e1aacc12d76edc7e31a7f05b3ec97acfe022dc37cd732b42d3d7e1bb5e1a72c5bd50c054d26a166b0f6140bf9fb3c42ed071c5cc36bf5f502575b6cdc1c3400da0693130bd415ededdd52bb41112ddee1a6436bd21b500628167a8e41055618749b5614272f6397e5bbec546d2e466d2517c8703d53fa858923ab56746620c2955f310bc5901deccbaa4b67bf7a2fe5aecc76fdb4d08410b941b190fce0f92111ddb656db52546105e439b0f4321c854ae4908026c2d7ae4db3c482a64b33df59fc040f58c92efdf726722521ade6aea57c93ff1dd807dc86ded39f681b9e69b1df51930c456e10de0ca4a20db879fe6f5428b1f00fb831bcd106e20a14d94732ec6552e1e16fc146031251e621311a83aa1afeeca90440191321042559367c91ca9debfcd8017ca841513095ba96ee7a3df10f69f6a861f91a086dbfab768da110e7cb7de6741738ab56585cbfbf4afca5b629ff33053b9d8b25761f968f842ff4c97e61eb713c1dee0b887c1fb2e6768294061d33b3b3e416b8e6e5cb413f8e8de8126d4c77bfe1e394e0d01f51b5a5cbbaf1689a5315ecc4703ce8b53556aeceb102afc1931d52b32d8d276af9c01b534079a8a57fddd6ee99259ecdc02c48080072511d768135412c0464b64a32dbfe43b729b0a60f63678ded77a77185c4934a8eee00ea44ebd74848a699506a3e049d4f5067ac81690d37f668c1efb1ce8e0ab05bc371dd47837105fe8e85cd27bb93a5328cd9289360e117f5d3ed4786a427f6a54b6c352914691b8a6cef7dad4ff8bd6c8846a2ef26ead3adf1a85690a190fe89d124f959f9c8108ebff82b5463c46b5abf2f9abe8703bc5dcf3f1084b19dce7c729493d802ad71190d2857ed4b2dccbc181d396cf9296175d45b3223fc7a2ce8482e9859eaa91aba2b14aeaf25ec25151de0709fbcc66fe0de36742025402a18868fbe94b432d47a5a30fc8019bdb61409939e7604ffd7cff45c17f4b420951a909b93926dabda4976b1cc922b830c8e24dd05b3489797e185897286db0398cda641153f0423a3405c12eba9e926cd0db7b6ec86029e077e62e86330f6dd1a38439aea4f02164bfaf94f863441cf4e28dcaadc69a2b22beac2e71de0d6bda5dc5919106827f763f246b84c6fb6f5acdc7a506bb44645ff58981fae8459e405fc46dc4130cc017e32b0248f6cbbd44d04e6c08caa05253f79ff17328d8da6bba1c27b0548c2df18fb3d5bb8c0367eba4f19cc409b5d72d2cc63192509d541dc0cbc7cdb0972ecc7043f6c7e5b9c7ee3dd52e6e40ffd45e3a67d9987b2cb486f1cd9dc96e9189151b41b3c3f54bcf1ff47e86a7f05dc9673ef41d84236f1b5465eb4769315cea68041ebadbac7dcbeaa5ab37645cc12372f28cefd57cffe2531be7350effbad27a3331c53b33aecba9a1d5128b78ce4d91dc0e50a5e04b6740261508db76467be73b24e58aaa6cc10b7f496ecdb79128a018e6e72879802c88cc48b103676023e8f7b936b186f084a29d929d614553bc312026097a95fb834984ec44ac4d5d5e1a8efa8dcfec1b8854af34541927b023a39bbc4188878086c88a20b5dece900330721ad0301fcbc631acf4a4ce56bd737dee94c52a52d22528fe80a7f2ba2ebc27123757e750763f643d906f0907ccb52d5ca46d32385406c9c9efcc2daaf3d69c399818ef7c33bafb8eaa9109e3c44a31e548cbf1465af324d7ca1efdcaaa8bae9c7f4348219da6f78634c8cbd4678673e86b98e130e7d85d0f8bea1e4a95137c464489010534b8658b0b85ad03b2fb6cd326b2e4d5fcccf8523f50de22ee219ba8b999ab41c3635d72ca38cf5c42fc9f9caf04fadc9ae25b8385e2f557de594b2033f87334c364b9f35d3226fc5a1156f08f35c84c8df3c1c550b1865af5733eed95d99f187a73ee849b12df1fb34e63dbc18987b630604b7b118f1117e3dc8fe634439d0a7783921f0934ce9c621f30b7cdab5c372020ad70fba47c633a6048052cc8948afa63f674cb656ebc12d9562788f3b2fb1e1d50ac746756839968a2fe23d4e96ebbfa487b776996bab3e2e36c5d13e1569f9df9021c4a13cff0a3f8290ad41ed609c6bf982e3bc87984a244a9830878de440b6afa9545b3ef676d62ebee146d846d7c6e9e2c2ac5eb462cafe11995111c28ea83a58dbe7663ec3a4971d3d7f736a1ca4d66b27f490aef15e45c70fb230bb99105f4f776e7513645bc3263e4264e6288c16e466980c3cfb2a62c509578f0be11dc04d3590f2798888b6854a59674dfc04d7fd3bfd0be9edc53340f620f6d9d12a2242e3eaad84699ecd4a10bbaac05926a2df20785b7877cc94ef30bf0bac50ae9a66976718960dd68443324bbe1f18b9c20035d47f91a1aa98ee790d64a895fe601784ed2760000ecad860a2bdd62bdc4287e11c4466c50ce41b982e9790f5adb29b16c72e1e25fe8be775e0cf72a6412d7cc8829d2336146252eaa1a3f198b4987471a70513f11ae1adae235891ef6b914b167b81f6100f220bf991d61108f11dd627c4362f3b1de44ffa6c484f28323e2888750077f1e7610f1322e121f6cdef42d0b80140f103241af6c9af6145ea1bde91d8498a374f9f1d3eb7a518e27de1e2e047614a3bcb657ef1c78a3f6ef1ab1a9dab5f51615c95f0aaeea49c381013a8a4e428bfb9f616db33fa0cf852d662b7daa1bc500363e178fbfaab78ece4a57e385a93dc8284ae0f8b79ff9c381cc643866e4a4100cf361a5ae86dac50ad3117a6ab9284485f269fded0ad459306d55e768351d7f2def895a307cc0920bb23a190811860b661e660fb2b8310e627183c3cf0d658853781a832ab412b027517e97be6aa49d3b1038bc7f12fc47efd790bec3a62577f476407e0ca73542b172b03898143d12510ef9a306a1c363ffdd7936dd4ee6d4a4f2f8e9c8e9847a019aea5463cb4a048d702b768b961fafcdc776a7710cd6273c0b125e3441552e2bcb69e5a181fbee111ff8ac3e047c7fce13aea1aa6505c0210bccafb5b44fdb509113c93dacff20c4c6eff5f26245534e8cb3fb0677c501595496d767bcc337e8dcb68ee602a1495032d554017a53cdaec3830ca6e94e4b3f05d39fd1c54ed8708e68b9007372f7f2877d0d670f8165381d590b1999c09fdc2ca5c043dc8465e691645f64d09191ba4b59c4b7fa7f493158abf385430f040e2f2d49922fcaecc065568ae2af9e8bf6cd03ee6dcf5d44fd4de11d87dba2572a0e42e3a054b762530b4a02f27ba8812d8dad4d347536fe7bffe185f035d6837929b82427ef17e4e4d9e2c0cf56ae1406cf3ff3ade526bb1912d5942c7c6c923966609618ce070f1d44b007d75aa73caae460ec6537ca3d0d5381a1f38f1c38fa94d2350597cb827370d966273cacdf33abe1a734f29058a313ea49810be05ef937a593bb30447b45134aa59b7ec694b7559eae9e59214b9883b0b03b2caf853218406eb55aa732c0b5179bf50a2fc1e265623b5d7466f2283b63cd963b3dd66f7af02d2863d787c0c39dd72326c9fac7e72c2e4d48b2fdc6c1feed1687ad0c9548cf946b8407ec6bc4642393a70dbe51afd850685ccc0c4e44a7299e6e1b8b8a5ac634ee5e0f0f4ea576dcabcc22b16c45e6811b34822ff250d90b0f3d7d040bdfa0d6604c2ce0cea11eae9bc61f0ed873232540c474b6193a303f289535d94d94b3beac47af2ea5133065b0cf13f22a4b729183bc1a5243c5bd787bc9f243f5e7cb12c697c4c6748ee6aa0fce42b75ccf7ad431b20f468c45a9b337c7ef23dc7369c6da7a1ff5f743be0de5206efe97333ea29f8bbb4d9ad48905e0e7cdc309fb8c917054ddd2a51227316e06844814379f7005d36245d678975f89e819c2396c3d73965e27aad6baee45238e377bb1a57c0c4f21e58a9229daf036c3fcead9cd3226c7b1f66662102dd153b461d6c614498c96f4abe83cf848000163ed49719eea2e217c132fad45015b48c5b99a7f7e647d43cb3c875fcc88e3fc4b13dcb1674c411d7e62873f69bf2e7810cf83afb18a40bbbd00ac32a19c9c31f31ae8df4ad7366f3bce11cc42f8e8753154d43aca9841bc44bc54b530903645f6286cb38df439556736d06ea5bfdcec7de1ac32ac7623df853c6803915dfc6db6c6d4639c5181ef0abb7cbd3f37e2db063ad4112a3d4ffcb34e899f131df10934d77d5edc145416f681da1fb81e034d5ee2908c1bac53d27460583f1c0e0ec740a2fe079322c961e448c9930155ecde1ee033df1c4c0a97e44d0a0ac01c52482b66deac76ae6c08b5cbde06b238dbffb904a46fa2a61c354e7a8bc1ac3034f6658412c247bdb1913cb072a0c706193928ebb92a3f02b21f71f1a5e8ea69b0ed91a2e88d5739210f0619c856cfaa9fef121d80fefd7238f17811678c5af3150fcc16b827973a2e25495e9d82ff334dfa7972ac30980fccade97801bb54a2ea4233e235755f870af720bb84dfa9fa7c7198427e725c8c33de358055594b4fc0fffb126f4eb8da898d03dfd9561276173af697346f83a7a58e8313a09b15287799c14d0a777f54ac78b83638dcd407263ace7926f0325c6cd35721d802c3f45313de668784f2de619fc41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416481493ff02caa37d5418b1cb7585b0afdba007fba3676c09214fdfc6ba199385713ef6648628e5c7f80cab6c3b3f7ed5a21a0454251a625c2b09a16c850e7878b5cbde06b238dbffb904a46fa2a61c354e7a8bc1ac3034f6658412c247bdb1913cb072a0c706193928ebb92a3f02b21f71f1a5e8ea69b0ed91a2e88d5739210f0619c856cfaa9fef121d80fefd7238f17811678c5af3150fcc16b827973a2e25495e9d82ff334dfa7972ac30980fccade97801bb54a2ea4233e235755f870af720bb84dfa9fa7c7198427e725c8c33de358055594b4fc0fffb126f4eb8da898d03dfd9561276173af697346f83a7a58e8313a09b15287799c14d0a777f54ac78b83638dcd407263ace7926f0325c6cd35721d802c3f45313de668784f2de619fc41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4169c7cca5890bcd8fbe138ad592971f9e37714e409ec982b4338375b1687f13d8ef4e3d714974cd5d82f6ac1b956f8a598cb10a0effd727d11e75bbc5f6f9466ec416e6407eea725abc79c6b2333a4d28fb104863285f357a64f7221647a928234a20b57e87e39cdf391978029250014c6b02fde64f3c3178f8b06143a44b696bd7d6031551595abea1bee9c08c090892ca04cb0647d296143668f040a58aede98014677c086c746f25a0878001072348f282fd4ac34262bc9535298e64476b0507b2fa7244f5b0b131d7ab8ebe89a13731377cbc10ce8b3769ccddf2c0ae2243596d3198f69412550202bf37945cfec7f0c933012da677f6ce07bb5f3bca7167380d14e5c1c4226db60566c270e1794c5db937ffb1542081ceac27f5a7775640c16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1639adf3e3b750bca0b66bf9e4288912768bb83e6021a52fbe6bdd4bb7e0273d709723d0c05238db74e677a1f58d06069b560dd562ffae95e309b2deb707f34015c88598d502d9923fbba15cef8589a6185214190958931d6376cd5c415684c0d6501e62b2c407f7d3955f095ac28215a1d7d33c5fc3e83aa734109eceddf08c78dbf839cd27c4dc31a7169683b3c04b028ad40b3a51c6b76df74dbb6ecb36f9462b5957896a33d26e014bbe866fbf2418a5bf721b0df8b9f6fc24d5c1ca101a908d50e0d725d90c5ba34d3af578f7b2a0a3c1287198af44937e65f1ee74b123b53aeeb9c18b2cb55b04a9b2205dd3cdc1153575d225aa26193cb233c20b45d2bab83638dcd407263ace7926f0325c6cd35721d802c3f45313de668784f2de619fc41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4160138c5370a24f6fd4a6fb36c6c99a8248bb83e6021a52fbe6bdd4bb7e0273d709723d0c05238db74e677a1f58d06069b560dd562ffae95e309b2deb707f34015c88598d502d9923fbba15cef8589a6185214190958931d6376cd5c415684c0d6501e62b2c407f7d3955f095ac28215a1d7d33c5fc3e83aa734109eceddf08c78dbf839cd27c4dc31a7169683b3c04b028ad40b3a51c6b76df74dbb6ecb36f9462b5957896a33d26e014bbe866fbf2418a5bf721b0df8b9f6fc24d5c1ca101a908d50e0d725d90c5ba34d3af578f7b2a0a3c1287198af44937e65f1ee74b123b53aeeb9c18b2cb55b04a9b2205dd3cdc1153575d225aa26193cb233c20b45d2bab83638dcd407263ace7926f0325c6cd35721d802c3f45313de668784f2de619fc41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416b31b36569fcc34ee16f5f308178cdffd294fb03b5bd067197d52a9bb142f3a5083cb5225efba1d1e36a5b9ba503ccec486d359ad27dbf26cdb211394245382d86f819bb80fb2b45be78e3598ddc615d2556ab9ca4f7b7efcceeea628f58d30216c3d00a3c3949d0d342ec61126b6e459364ce0c1dbfa4615ecb8ee8337dcea23f03217f218d4bf2a99c5ea711a249a7a8145fde300aa5b49713efe509e3f9153f9e4dc18f8fac4b98cf1ad28333359780c61a0d83ee7926d54d8e96aab1f7cf38e8d5cedcf1f3f5b9ff545060dfebe7b76d40aaae81a7e67de8cf4a31fa533689c35f2599ecccdf984aa4fe44952a54a4b29937e36068741c522eb0ce29eb0763ff379a1d344409d5abb6510773269c61a49905f263bc949fafe3403ce26feab16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16b6228acf48e6084ca0fa3d5686241375e91885b9d7aa0bf6c5117f747451ddd5ccf6b265f6236d43f49d9fb17ea3850b169704ca641bd86144d228b95aee97cfee472cd965f5b45a01bf1bb9ee74a80705adaeea334c8da8064611b99e81b0bf0b0784dceca740d568fd5235212b9de1d59718cf7341bfa21f67cfd0d98361160a896447109ce2d1ac3287484dd71a8f12be84f4c84e5bb9d7ba8daedab66dbab48f398d35fac2ac9ae9f495cf74fefe68aca94754fcb8e8ab8ff30f338ad6be3b1ec7f30dcf606c936495ddf9ffba06d61b80d858f78019f2251489bcaa3169b8ab8e2c32218e8dbfb21d783538fde00fcc532549e864797109715dfad787b12d60f2308bee7f72569de9f27154a1d31b41314b2ab014109577cc7beecedfdcfd8cb56636f2bdc7d2a4f11d592e78809e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb167e9f91b681f4807fed97913833158a71e91885b9d7aa0bf6c5117f747451ddd5ccf6b265f6236d43f49d9fb17ea3850b169704ca641bd86144d228b95aee97cfee472cd965f5b45a01bf1bb9ee74a80705adaeea334c8da8064611b99e81b0bf0b0784dceca740d568fd5235212b9de1d59718cf7341bfa21f67cfd0d98361160a896447109ce2d1ac3287484dd71a8f12be84f4c84e5bb9d7ba8daedab66dbab48f398d35fac2ac9ae9f495cf74fefe68aca94754fcb8e8ab8ff30f338ad6be3b1ec7f30dcf606c936495ddf9ffba06d61b80d858f78019f2251489bcaa3169b8ab8e2c32218e8dbfb21d783538fde00fcc532549e864797109715dfad787b12d60f2308bee7f72569de9f27154a1d31b41314b2ab014109577cc7beecedfdcfd8cb56636f2bdc7d2a4f11d592e78809e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1628675755c5e793fb9d4a4dfc1682de9bf392fe6456992dbeacbce9102a3996708a0e46a0f6bef83801c72303b83ded59a219d3a396cade631ed657f27ae42067fa517df05bd24d175926411d5effc59c01910bf785e75ab612a10fec4994067b92772cd4f7d73bea62a956876f7f6befdb937f614650d7f35152d32b9f27612ac1347e1fb9a8071294d868b9717bb9bb6039c159012cc8f7994c4d8eaf35122516958e46786c8380adb69d895834e74e90b1a27a9f8b08f508e5c2fc5b20f318397fe35f131765059af6f95751fd36526423c72b2386b66a730c5dde6a672169c770eb7a0f54bd3f0eb10d01a7b8c33383d7222907990ef0b2cfc2edb817b0edc9c2ed8576208f1c638bfb95d4cbcaec0c5c8a10696d41ba007896bc324a90f76220cf483ee7137353761014ae85132fbaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4160fc0c461ebf9fc1904f7ce3298a0edb5f709d3ba57637ee1124171da07920e76ebb6b26514ee05e703708496712c77e6152b9204a4e990f284c3af1680e7b2aeca970238bf2848877c41d7144a4e7d874b3c5bec957c3f3d545857aae9b976fa2267c24b16e84debc26cd99b569f3debde8e966928fdde5b19a2e97e9e02ce407144785a495a9980267047e08a13702c3106677fb354f765ecd446a8d979ba27acbd29365e4ea9f2b361e3d4b4ff18ae151fa7929e21c34534da8cf6bbd018e1953f36d0b6718d96c6d503b8c6de47995b6771fad8c9308dec731124fd304de183d230934dce87258c821e246a8ad55e81cc164b087872664b33c851f2224d4a70af0cc83585182531c3c4840f3a92cb61f78b81f89ec2fffd912a297bf555a96f94f251d28d300d2e3065f07ba61d1d89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb1608acc746584b5562e4e3b2b5bd6baef6e28e9208f5f59f1d689ddf9715842030ebb6b26514ee05e703708496712c77e6152b9204a4e990f284c3af1680e7b2aeca970238bf2848877c41d7144a4e7d874b3c5bec957c3f3d545857aae9b976fa2267c24b16e84debc26cd99b569f3debde8e966928fdde5b19a2e97e9e02ce407144785a495a9980267047e08a13702c3106677fb354f765ecd446a8d979ba27acbd29365e4ea9f2b361e3d4b4ff18ae151fa7929e21c34534da8cf6bbd018e1953f36d0b6718d96c6d503b8c6de47995b6771fad8c9308dec731124fd304de183d230934dce87258c821e246a8ad55e81cc164b087872664b33c851f2224d4a70af0cc83585182531c3c4840f3a92cb61f78b81f89ec2fffd912a297bf555a96f94f251d28d300d2e3065f07ba61d1d89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb164dc3aefff257622d895e917836dba84633ed0c6e1ea3cc46ec693f64fab73f4c6a52fd192e2d5ba2b69729636b673c8ebd3fc55980c847d5f65792bc2aa3e00c8726c095b6de677eada4ee63e2986b8909144fab195d351e74b043d9515f2bf26cc15e9f4dde7cf31eced37edd175a84ace907e4e7328d884de848376aec1ab57a38dffdc80fba20aa1d6c4599280523567c5e4459ef15e291a5c7e21bc5847669d00e9654c6d1632afa03bf733d7392dc707cf31442dac06eb31aeb37db051351b9028623a245b7c62dc19934862613f002ce9502b7af7691d978ef5655b352d92cd9890de37f594e89a7a9c8c06bcccd41335fa5589a466e3995d137c35c8edd012deaa0b05494eb64f4a4feae4dbccf852dc3c78659d2a0472f496bf1b0986f94f251d28d300d2e3065f07ba61d1d89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb165d5f9fc864b5ea369941f7dabad3c14d652b822240a5819e1722cff924dfde95f4b0fbcf0bae583be9c2309405b3ff75c454955b6a00d705015c9130728a31248e72855a792e227e1ae45d8ca1603d0c6819f0511b49bcc276d65884174b4b04d58a87eb4c77f491f1f8195ffa548aada8e179d8b445ca2b7d56906a7c901eafadae07335bac1a7bb0dcb2254fc748bda911a40d2d340eeb983409fa062f795040a89bc082da33ee703fb9ecf51593afcf7f31cc9affe3ec3321900950e3b02ac24e20ac7089584293122827b6abb74b7a3bf6d52635b841a673b8027af56dce3436e0b5c03d755c1c9066e56957c4899ad2c300dc81eb98bae6910c7cf223209336da51dfb6e659ccf17cd19ee871ea1b41314b2ab014109577cc7beecedfdcfd8cb56636f2bdc7d2a4f11d592e78809e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1697e9b75a3e19513c9e376286e946d8d6652b822240a5819e1722cff924dfde95f4b0fbcf0bae583be9c2309405b3ff75c454955b6a00d705015c9130728a31248e72855a792e227e1ae45d8ca1603d0c6819f0511b49bcc276d65884174b4b04d58a87eb4c77f491f1f8195ffa548aada8e179d8b445ca2b7d56906a7c901eafadae07335bac1a7bb0dcb2254fc748bda911a40d2d340eeb983409fa062f795040a89bc082da33ee703fb9ecf51593afcf7f31cc9affe3ec3321900950e3b02ac24e20ac7089584293122827b6abb74b7a3bf6d52635b841a673b8027af56dce3436e0b5c03d755c1c9066e56957c4899ad2c300dc81eb98bae6910c7cf223209336da51dfb6e659ccf17cd19ee871ea1b41314b2ab014109577cc7beecedfdcfd8cb56636f2bdc7d2a4f11d592e78809e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb161853eabe0b3c84c03d42d2f97660bb28fed5b36b8dbcf47f9214cf3183a7204da8bd7161f7e198a2e9d4e97353af1bd91a54c4a627e5dcc8659537bfdf394bee62fa5ff655f70bdd1c9a38851191778e60b408009c3571abc77bb7f44728b7f78e3da354b80b38981bd08e7e2c9c6508b437551aaa0075a8a143a69437454f024e6ea1ea1dba9f55fe5f0070ccf52c52dd2c3e64945877ffca4d35156decc4659ac4aa5b002811fdee5c4acf70d84625ee86dee4f518f04e3ac40d05f4ac687f84f410a9375e73cfc129c3a8a4c8408dc03052b0c375e21b9dcf7b709ff011d24e33210d14f8ce45849ff1c3f3c20cdd7524d3216ff910a7bebdfead2c9e742cae4806236da0328b1f7cdb2f5ebd714fedc5fd902162be1bf1a410dadaef828bfd8cb56636f2bdc7d2a4f11d592e78809e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1624099e2edaf317f19f734de62471433c05f988ec7461f06bffff706a7bead822b871d5754981ff214db005b84733884f28d3fc707cb393085d07c104d26b4c4506aa96b19c0a7331001599942d4d872de007a048d0574c2b3108708077492c6e153f3f4b072e9cb76fe6530b2565fd796d57fdd4b587252713fe00d135ec7498104fcf6960ccabad6a4c464a670ebe404f6a463fd06ceb5a36869aef76b8a08276478e76cea50f1d8bbee6c731b7c4266a4f1d2c122c6abcfec6836427cfb7b41b9e465a282c1682ccf7f9755a7311c9ddecfcdd91e3871d67cc9cea284ce5ef05aa84da09b7e9716114f971febe54df6bb94e91f35267696558c0376c05fbf39badd8cd3121d3203d99e6b783d39d409661f933dceb64d4e5cec789680c3b9148e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4160b4c7f66b3546320636480ce9116af8d05f988ec7461f06bffff706a7bead822b871d5754981ff214db005b84733884f28d3fc707cb393085d07c104d26b4c4506aa96b19c0a7331001599942d4d872de007a048d0574c2b3108708077492c6e153f3f4b072e9cb76fe6530b2565fd796d57fdd4b587252713fe00d135ec7498104fcf6960ccabad6a4c464a670ebe404f6a463fd06ceb5a36869aef76b8a08276478e76cea50f1d8bbee6c731b7c4266a4f1d2c122c6abcfec6836427cfb7b41b9e465a282c1682ccf7f9755a7311c9ddecfcdd91e3871d67cc9cea284ce5ef05aa84da09b7e9716114f971febe54df6bb94e91f35267696558c0376c05fbf39badd8cd3121d3203d99e6b783d39d409661f933dceb64d4e5cec789680c3b9148e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c41623a7505148d00df473790899789052785a00123d78676f8935c784a8ee14451cf29dafc9dde776e2f00914e1f047e3a20afd1f32742a2b777ccda726cda746fda978fb480b5ca9961623924c9fad6b824511eda93d42b6db55e79835d2baddc210b4abecec187f223fc72eb04017edd3c606fc35ac68580e59c5189413392c45c6206d676c82cacb010013c163ede6677402684064baa1120da24a119cc0e16768fa068dee603b75cffcb10a28c3b6f3f1dfd071a7904b1de8ac85d2fad3c708b1611b01d03bc1725d71130b6a9ddaca54d0930502a791f5b5063e88c221fb2505ec0a62b1bc6efe9fede4be01a511e08e9846d2673b2ac48fa48b37638e99b4142aa7674d60f5b81d415dc4013eca4471d89e3150fbadf81a8d31ca92d6b35848e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4166f8d5900df6c21de8f7001b286085a7db8f209edefde58cac4a5dfccdfbef017200d49157a1ffbfe3b5ac909083a16983f988978bfdbbf81a6881f46c79b24b9a89731f037d0199e6b50e0e5400c038d3b2dfcbaa9b4c849316ae1d2bb12e355389e7b58a510711d547e8788d12e78fa1711dc469af35d32236cf9c5a1d845e6e7e7fe023ea58c0a3f5d29f408448c79b70293746a5614a90d6130124c5645ba83f1cdeb88c5cc357fc54d7f5d1ee2b7b8372ba467da6ad6441aa8950977577c4eadd91e725fad8ee7c42736c583fe05c4628a822739085d7e068a41f2de8f9aa52ba54c5b8c1edaa774d1443a324fcd5486cdc99168ad81ef2585037699862e27b8f8f1aefe114d13e91e5f75b885bab8d4dd7835d62f358ee6afcd81cd9ae082dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c41616ad4a4422809775551dca7aba95a315cca38781859a9c2f6caaa69b0930283b06afb59bbb260b6c1f76d596349d415d9a5c341260a9d7510907fec09e695c70a89731f037d0199e6b50e0e5400c038d3b2dfcbaa9b4c849316ae1d2bb12e355389e7b58a510711d547e8788d12e78fa1711dc469af35d32236cf9c5a1d845e6e7e7fe023ea58c0a3f5d29f408448c79b70293746a5614a90d6130124c5645ba83f1cdeb88c5cc357fc54d7f5d1ee2b7b8372ba467da6ad6441aa8950977577c4eadd91e725fad8ee7c42736c583fe05c4628a822739085d7e068a41f2de8f9aa52ba54c5b8c1edaa774d1443a324fcd5486cdc99168ad81ef2585037699862e27b8f8f1aefe114d13e91e5f75b885bab8d4dd7835d62f358ee6afcd81cd9ae082dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4160866b64d0b597125dac570807f75315e8fa75098268c88e795b0f96752242c8129b6d13a5d1dd1b931e2b463ae9b87aa1efe59b344c486b4c32e521a8732f9d42b38a02d1281b12fe55565d5364dd5e0610cc1f6e6e3bb157a769bc1f50aaaab9e3f27cc8b9d3245d9bac7b10391331e41a5b23ff038e871585a7bef2fe59bd4e192f3e8af6ed7c79462cad7fee4fb482860c81fada02ac2b9c64527e1427496e5353288a8afcdb7d4845812d3aaa52d90bef408956767f26046e33b2b614ffd5da89aeb38bf02b0312d2f770d811cc65589699267396bcd72e0daf4d0d909b031e3580b02dc2d066f7c7e9d9fd4ea05d5c64599225505c220b14456e004bf5a946799885e9b39e3b9b3c211d0d8533acdfa6f2db7a0fe47fac5588be5c2abdf82dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c41662ccf67f2dec2c8294634cf94a8c2378cf360a7b08b50efd02c3e3ba046d8a2d6957a0de4ffb5a7da9129c65661baae8942a077241e7a0958b85770efefa505af7207c299626b48dbbb93c84ae5d3000a95f140cbdf9a111765dee390d95a1f26292005bd8710f17723405e3394107e6eb6ffd22e433731fc9304048df33d3b72cd5c12ca326158d578e83fa5c69aff345cb0d8983e0084a37be774ee6b6c5f39b8e2a0f9eea77d4a8961148667d2778f2dd902d580ee6f42fb0d4b215a3199e942e59ee1c853ace05731e0aba29c2dfa5b67e00744e25d7da81b450373af1f2f26ff14414046c13c623cf48a7b0e6b6e15568b66cf973c8b0661a9fbd71a068e630dc1873a562b74bf30c40ec651d3d9661f933dceb64d4e5cec789680c3b9148e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4166502907ea55d998379b971123c4e9aa9892f8f81cde84a31fcd0cc8f4ead058f6957a0de4ffb5a7da9129c65661baae8942a077241e7a0958b85770efefa505af7207c299626b48dbbb93c84ae5d3000a95f140cbdf9a111765dee390d95a1f26292005bd8710f17723405e3394107e6eb6ffd22e433731fc9304048df33d3b72cd5c12ca326158d578e83fa5c69aff345cb0d8983e0084a37be774ee6b6c5f39b8e2a0f9eea77d4a8961148667d2778f2dd902d580ee6f42fb0d4b215a3199e942e59ee1c853ace05731e0aba29c2dfa5b67e00744e25d7da81b450373af1f2f26ff14414046c13c623cf48a7b0e6b6e15568b66cf973c8b0661a9fbd71a068e630dc1873a562b74bf30c40ec651d3d9661f933dceb64d4e5cec789680c3b9148e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4168678855bd06d8e10a1cbd20ef9bf6d2ddb930519294b6227ece3bcb480dad26a9eac6d193fb717de6deaddb2af4f7f4f16b61f1396afe730bfdfec65c4be580f55db56380b75a443bac798404a89ea36271f7ef273ae8c16c63bf2e948f4b6a6e8ca0a4df3072c1bc81d14c7b3043a51b038f16e461294ccb2ecee60f56c465bf8ab6c577ab54ebac60999b946b82706b0278f0c2c1362ca3ac7b9372dd122eea1cb2a94e6b23256fad6c5c5dfe24507ce9d83533d2fc59089af38dd4009452d8ec30c0ed27d0df6f8a60bd34a7fb79b328711b435e7b34acdfd6a91ecfd553090d16afc342065d30c33ef8ea1307eec6bb94e91f35267696558c0376c05fbf39badd8cd3121d3203d99e6b783d39d409661f933dceb64d4e5cec789680c3b9148e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4167c28d3d26b1f5f480214601be14b0dcbd996ee2cae972e2262afb5a563689b5eae4af560e592ea9f974416ac424f39fbe10d2565cd3e3db1afabc6a4dc45a862d50852fefe9329223a07fe524019cb79bc003bfadaf91942742c48fee5a9bf03ce4a8566930c5525295af96e7d5b2b98221265884ea0911b34b8d8cee9510c66db9f62bd4651957a53d4e00e010e9b2b9d3547adbf668e26a81b35a1c8d6ee83a82060bfb560dc95d9a63ab0b0b5b303624bbf4c2c34181ef3830373f5692407a110d976a8c4202b0e157c6bd9ed90e15b54510773670a213ddd71d0562065900cf77a3e25198c218285b4c7c96599ef586ebcad030c6724e1e4937ade113b83704ba1a797687da7f96b169c08324e6aff8c72cbec2a3259d6bddf524a8427f677f74ee8a8abaea4311cc72f9a334b0567c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4164575810127dc97d584cf58464bdc9c2832b2d0584e6a36d552e0ac9bbd7ebb1b16bddf50494e7b90b6c7c72aea91b58be10d2565cd3e3db1afabc6a4dc45a862d50852fefe9329223a07fe524019cb79bc003bfadaf91942742c48fee5a9bf03ce4a8566930c5525295af96e7d5b2b98221265884ea0911b34b8d8cee9510c66db9f62bd4651957a53d4e00e010e9b2b9d3547adbf668e26a81b35a1c8d6ee83a82060bfb560dc95d9a63ab0b0b5b303624bbf4c2c34181ef3830373f5692407a110d976a8c4202b0e157c6bd9ed90e15b54510773670a213ddd71d0562065900cf77a3e25198c218285b4c7c96599ef586ebcad030c6724e1e4937ade113b83704ba1a797687da7f96b169c08324e6aff8c72cbec2a3259d6bddf524a8427f677f74ee8a8abaea4311cc72f9a334b0567c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416eff47cfc9a99853f47984ba6fddb3bc64c6151658a03b23ce9b571ed7634df1a492a69e7eee647b93ba6033ef6c619027ba8222c7a1e0531385a3f1413b2bd829c038bf457a2d7ee005df65ea2f1734f6cf3abf42945e5ae0cf37e1312d01ba737aac6fcd9115d1e41c5e31fbc516ceff308e12be48cf8ecdf22e2b8483553ba6d215815f9f707066bb1dc120dc041565a5177c03b822777562abb38383f2253362f59a0a17e47ce53a65d9d9835b5baf0b8c304b73bc5719a15d294a076ed6519a984c861fb754ad58dc4b498f78f6116ada3b3997ff75bd106d7411d83126daa5e8d051ebec8221ca139471c8a21d688386b202e9cb3c7850e8bd669e1d2b763d8e91096eae486f41300ecd14acbb4db937ffb1542081ceac27f5a7775640c16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1645207f1a3dbc1a205bd6530d4f731165a1273788d5e7caa694d24ec0b7d83b21f067f5000d202a21b50d4c3dc24c411215abc620f001e84743b79bd3d9c11999baac81244574922563b741b01c8afbe9f57286483cb4a3ddfd4e5ea37163088268b3bd104f26e4cd4801d0a78f229bb3701869e7d39c2bc979480d9b2c72bb28da3e9a8f9b78836b7f3cdf85501491f7e8c4d361f35587c0924a54e14b4653a0549aaf00b6d586c8a5d28ee4dd4921f7abf45d3c0366a5c0ad8cf46543bc4a0f960a3ddbac042fa0a8c1dfd1bbb3b29ed1b7d4a91387c3c1ddf3a200e255ea1bb08adb6145e26f7a7a40901f8c974b314166b1612e48e1ce6de5be2aa66ad4b7841feebe05cea6b73260b87c9c259598432d11633ced1a5523ecd3dd5ecfe73713a152183991c8448792b4b2616b663267c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416a62a6d6d25052d73a68df41cecbf5ba1a1273788d5e7caa694d24ec0b7d83b21f067f5000d202a21b50d4c3dc24c411215abc620f001e84743b79bd3d9c11999baac81244574922563b741b01c8afbe9f57286483cb4a3ddfd4e5ea37163088268b3bd104f26e4cd4801d0a78f229bb3701869e7d39c2bc979480d9b2c72bb28da3e9a8f9b78836b7f3cdf85501491f7e8c4d361f35587c0924a54e14b4653a0549aaf00b6d586c8a5d28ee4dd4921f7abf45d3c0366a5c0ad8cf46543bc4a0f960a3ddbac042fa0a8c1dfd1bbb3b29ed1b7d4a91387c3c1ddf3a200e255ea1bb08adb6145e26f7a7a40901f8c974b314166b1612e48e1ce6de5be2aa66ad4b7841feebe05cea6b73260b87c9c259598432d11633ced1a5523ecd3dd5ecfe73713a152183991c8448792b4b2616b663267c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416a988fed57837179633aff41765d4fb5786179f3a3b2a81f6cde4eed5a3a148f6ff3ffe0d33920578b81e9a1d3f0c6b7f4bda20bfa309150222767f9d507b898ab3dab8f44523f65ebbd387dcfd0ab08083601cf60cd3fa0c3ae80a13138a03ad8ebc851f0bd41c2f1c3acf3a9c1e729343603b71a712329984b790ee035111dc1bf4f0dbb50ac5b8a70275e4642929fced807e6d0ae77a10a7819e5576388fda11666b28fc67f1735f560b1e5a977afb1c2e0131bf1fd7c8a4e5fc3604d91e622e153edff7d4805cd05f84f2f28a173838dcdd945c93993b606aa57c560a2c978e401858bd252fd13d8fc169af77d4c39261a1402e1f5f5f66d35058b534440dd27d38cb20e9a2c225fe5ec9c0cb1d581ea2d22103d999ae9e2981bfabf39b4777de1f496eeb4525f602187cdb005a7e89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16062a76db29e4889e7649e82fc235ff108bc907030d54a7c1d1566f66808e7b20d108843ff4e6fe39b91d31abb7f45867eafe5110925f2a0317670539fa08dbed757088d57768211d578c4112701025b9b302a39ae68185ccc08e75b0a8521bef666419af6da9d061724dc385a1f3f15f39e1e421f134bcd6193d1521d1b1029cc3d5b62dec342690d8afcd2c113bdf1c144ad56a1de6ee8d0515f92a7cb6bbb71f6d19ed56c47c9946cce458cc3fad7048cf5b0ccf9f9ebdfbf43f661ae00b988eb128d8c29d6937f15d1b3de961e44f32da62c0bb7c8d1935c4c10ae4ab92b1597137fd980ed89e9de581e5acf7f15bd6cc1e69ec1a7cc4c1d1f4624f7e5692d605c755f3043ddf02367003ef1a69ce4e967776d8afa8d877207b445bc8d10f58f8581c29d6bd9badee09b84991852da2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16664936d22d660e8f5804d5b91217d7f48bc907030d54a7c1d1566f66808e7b20d108843ff4e6fe39b91d31abb7f45867eafe5110925f2a0317670539fa08dbed757088d57768211d578c4112701025b9b302a39ae68185ccc08e75b0a8521bef666419af6da9d061724dc385a1f3f15f39e1e421f134bcd6193d1521d1b1029cc3d5b62dec342690d8afcd2c113bdf1c144ad56a1de6ee8d0515f92a7cb6bbb71f6d19ed56c47c9946cce458cc3fad7048cf5b0ccf9f9ebdfbf43f661ae00b988eb128d8c29d6937f15d1b3de961e44f32da62c0bb7c8d1935c4c10ae4ab92b1597137fd980ed89e9de581e5acf7f15bd6cc1e69ec1a7cc4c1d1f4624f7e5692d605c755f3043ddf02367003ef1a69ce4e967776d8afa8d877207b445bc8d10f58f8581c29d6bd9badee09b84991852da2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16e76c546fb204d2a1044c9c17bef3a1578145a7212e5ce76af4a5e15d9a599b4450adc3a9cb081a068105df7b3e6796aabf10ae1f7dc9555ef6566f4554704cbc41eb425e6df322b63482e931b0f23abba48f69bd81d25590311d00a96e6983411dd1088be89d813e5df9a65a1c5ecae6482e94ef3a9c9f75bc8dc60c0795a9bbda4309cd5c82bdc2105ef20a0d6ee9e33e13b6ce45d2393e90986fd58f252cf40c3a1694fadda4404531f7dee65faee3173d508fd9919dfccdc93a2d309d5de867552d89a315b8698b04e3cbf4d4de0a69796446ef71946f663ed5ac34312bdc33b1701d29a5503db790d75097ea51553a6dc14a9d23376839b9a538a69e4925e630dc1873a562b74bf30c40ec651d3d9661f933dceb64d4e5cec789680c3b9148e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c41612d96e04ecefd7e8dd49d253e594d45bc640520f9e553b2503394bd5fa500c05967dcb99e9dad5a30523585c7cf4daf8e7fb38f9b21bf6b1ecdfabec235a848ed56a44d863e31017816d42346cbd07a3c836cca11d61853b20aae95147632fe85804885718c04a180bac0d8204314204d73a262c27686310ec96ba76b900e0726c524799309f89e87cae00e7c8fce4a4c7f03217b2c9b545383aabf0d859b35656a1fc37f1def0d3e81d89e3af37cb65d26ca6ae5424e9f381a2cbc0f489e7460116d6ef4cc1c54d5065a9d2f5f54fd92444e06727872eac5828609c914f4606fe6edd3e4c3fc2c0ec981693169d6df9d8a411b53bf057988c332022f8d8413ea3a3871c6b1b49b640482e97e5ec6d61432d11633ced1a5523ecd3dd5ecfe73713a152183991c8448792b4b2616b663267c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416c3b1ee1e17f92a5c09ed5ca45f41df658d05a009ae724233c2eea1a1019e6e7b967dcb99e9dad5a30523585c7cf4daf8e7fb38f9b21bf6b1ecdfabec235a848ed56a44d863e31017816d42346cbd07a3c836cca11d61853b20aae95147632fe85804885718c04a180bac0d8204314204d73a262c27686310ec96ba76b900e0726c524799309f89e87cae00e7c8fce4a4c7f03217b2c9b545383aabf0d859b35656a1fc37f1def0d3e81d89e3af37cb65d26ca6ae5424e9f381a2cbc0f489e7460116d6ef4cc1c54d5065a9d2f5f54fd92444e06727872eac5828609c914f4606fe6edd3e4c3fc2c0ec981693169d6df9d8a411b53bf057988c332022f8d8413ea3a3871c6b1b49b640482e97e5ec6d61432d11633ced1a5523ecd3dd5ecfe73713a152183991c8448792b4b2616b663267c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416fc122f81db8518dcc7f6c63045bd08de01629753f02b85f222944089db17f429c5cb00fc17d071db2383e3983817148eed8e6a124b40171b28cea518fb0e3a32994333cde571bae9c35fce7bed5c6eccf18062a544a0ab2a35b912e2e3c9945c9d41c8acd55de1e6db81d830a754adc415f807df5738117f58864d6366aabd7ef72075660de4f22c45bc91c355ab74f42329d40b6afe6d98cabe81f053b279f00ddcc8f3c5cf41004ce3e709938e4433becdf56d862c3be84741cf613e8bc378df5ad762d02482cc94f9adfa9619e33813aac3fab80f9927cfbff4597f748b59db43b4701f491e75a7af8b41040569304616f7df4af2445f7c9d3c85f2f1961a16a9c310ebee461ca03c083aba13bd6ac8043808a2cb1d5a04c94edc5d748bcf77de1f496eeb4525f602187cdb005a7e89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16341e0110f7abe622ddc05685f289f78bf84668e0546cb3ae7ac98fc9bb731bcb8002d1f1be1e54cbcc9f2380c640bd30feffe6c44bd888c74004450e274f34e14e8db6a7ff4de6c3723107e7418102ca262fc07370d6b19c29379d1d8460608d2ca1d88ec27009903c87a4e1a02eb0b16b05e9d1551ff2bd8bb2a5e19694ecfff2bcb43db8f0a8322d2246bb7e992a115dfe7237ca8ed92cc1a3e696be0f281018952b3c5455fb8b279fa0a541e55ad02d63e6edb87e13c4166db794ab7848babda7ade041b42c9cbb7101050a02b73c690c9e30d3e64f35cbbf7c59b26118f031dc8e0ded3893eaac75570655a2455e44563b3112345c5a6a6db4081526d1157c1d5dfb7c9763ff4abd816104ae19788184139d60f5223c4123a324d3e6a620dd4b321eb4d8f9c7a5a96092c8d69e7fbfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c41685834ba9fac0a47daae7ed3d3ae5b136f84668e0546cb3ae7ac98fc9bb731bcb8002d1f1be1e54cbcc9f2380c640bd30feffe6c44bd888c74004450e274f34e14e8db6a7ff4de6c3723107e7418102ca262fc07370d6b19c29379d1d8460608d2ca1d88ec27009903c87a4e1a02eb0b16b05e9d1551ff2bd8bb2a5e19694ecfff2bcb43db8f0a8322d2246bb7e992a115dfe7237ca8ed92cc1a3e696be0f281018952b3c5455fb8b279fa0a541e55ad02d63e6edb87e13c4166db794ab7848babda7ade041b42c9cbb7101050a02b73c690c9e30d3e64f35cbbf7c59b26118f031dc8e0ded3893eaac75570655a2455e44563b3112345c5a6a6db4081526d1157c1d5dfb7c9763ff4abd816104ae19788184139d60f5223c4123a324d3e6a620dd4b321eb4d8f9c7a5a96092c8d69e7fbfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416d255aaa56f5c57b726b9aed538f1eb9ae9e31fd43945089118ad867346246a3f5f6b1b6f9a2df7b6e9e71398038ad3b8cc9deb37ce5a93a12c8d3cb1ddf3cc29d219a848bc2aabec92dd53a65ce8ddf866c46b72812a580916294fd0220242cb7f04156ba3c5d3b5e8f8ff9e739bb05e7dfe7b9a0893abb4403a9217b63efa54681603c1a86388eb74bf25bd67954eff89cc6cfa2ddcf77fcf177644884104f0f1e4e454ac7fca7c3aebed8260986113158f133d27f147982920b99fd94b07b44047f7daafe0d6cbb4aba92ae5379171e97f7ce4c68b61e149acccaed284c1a4491f70e20b898c5dec9abf049268d50d0fb4652a66e2c61e7b883bb56bb05ed054b47db696d39966ffc29842021fee928184139d60f5223c4123a324d3e6a620dd4b321eb4d8f9c7a5a96092c8d69e7fbfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416a3473c36614f95cd8a4d0321f824c5ff357305afbad4cf65c30b15051780b2e38acec9e34533cf54f08e72f1db8378f334fd94ddf49f437aeb6dbef6ffb14603ec1652ff64b1d97e9430b65ef02f4eb9a1a771b20ac41c6593611f408aef497f8557955b3c9014c885be397565b62fac63ef32284aa599d7e3f3d5a7acbfb05c635f8bfee2bcfb551dfe2eda0385e33977e3a11121cc70f4fe024b35fd80c11f9dec1e4927c32562bfe99afbcdd8a6e19601e23fe32df306cf4f94e351ce4a4b4a4d78987663ea771e53c2f252d6e7f381599079c35596e8aed8b7ad8ee0050c84e1e290c237b119c5c4b41fb14fd32685ed1bfa98f8d3627fe61f6097ad91f3344d4959e078c5898a4c96a4750409f7276721e6084668df533084cf36d12a24f579ac03cf23f7701913e6de90dd0b04a2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16f5d292dfeadab9844eb51a5b313561ba958c67b723e256434eeba4795009a5148acec9e34533cf54f08e72f1db8378f334fd94ddf49f437aeb6dbef6ffb14603ec1652ff64b1d97e9430b65ef02f4eb9a1a771b20ac41c6593611f408aef497f8557955b3c9014c885be397565b62fac63ef32284aa599d7e3f3d5a7acbfb05c635f8bfee2bcfb551dfe2eda0385e33977e3a11121cc70f4fe024b35fd80c11f9dec1e4927c32562bfe99afbcdd8a6e19601e23fe32df306cf4f94e351ce4a4b4a4d78987663ea771e53c2f252d6e7f381599079c35596e8aed8b7ad8ee0050c84e1e290c237b119c5c4b41fb14fd32685ed1bfa98f8d3627fe61f6097ad91f3344d4959e078c5898a4c96a4750409f7276721e6084668df533084cf36d12a24f579ac03cf23f7701913e6de90dd0b04a2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16d99646e0e92c74e8951dca86a797c18d6a44f53bca33841c6c68da8f2873785af6b25dfe92ed0181173055317c7829b95f56ea2d25e96c9baee8708ad99408cf041e43a692dac3cf52ae42fbd3e0aae33e3c65a70534f5069707d928fa03bb41b1fe15c1a57a76d8fdabd2e28187ca75d211ebb00a0b6225e45e900bf736449b839f9037be3802f7157e68485d0589203139c9137baa0492ff6dfac6d5c871496b598c91555ae9190185f5247324401d35d07be39cb503c3dabe7a92649b8b2421637fb9228ef9622d90ca02dc575330996a107faa1fc962d42eba17eb14bd31e13a0c0fc8eecc00aa000491319eeab28e9846d2673b2ac48fa48b37638e99b4142aa7674d60f5b81d415dc4013eca4471d89e3150fbadf81a8d31ca92d6b35848e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416e35f8166a4c04a6891ffefd33a88cefb9c9a925f6273430e62be25e2e4723f38ea09028577ebae91fa396fd54c73f6b46f989efa3150c55f0741a5eaf2ee09764e24a86e8562200c3124ddb3581195037ee80ccd04948808ce8bbb59bdf7d227ef27fa49baab1ac43f46ea15f80722c3929e45c0c048c3c5206c28ce45d82fa16cd0933e0d4d6f47ce6f0900dd33681470a007445f7336d600f5cc4e670447ee0130706c14584a06589fafb018ecec1c18d9dd2f18f5d957127a8c8e1334fbc4bd04be5285649111a1435b00e42886806001e979d8dade1a842d7e7d419c387f2dff56f3d84c681b1ebe7fe004874db4ba37c4cb1a1ebed936cfe11c5ccccf435e340f34d448b201aafa02352fd693c1946825b08e59eccf820ce32b3f74db1e77f74ee8a8abaea4311cc72f9a334b0567c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4166d83c82727925ef75fea1449eb24a7a1ea8b1a4d1f3a5c63c89fedbe033b879317cb588ad671cf2b355a5e0aa0721c166f989efa3150c55f0741a5eaf2ee09764e24a86e8562200c3124ddb3581195037ee80ccd04948808ce8bbb59bdf7d227ef27fa49baab1ac43f46ea15f80722c3929e45c0c048c3c5206c28ce45d82fa16cd0933e0d4d6f47ce6f0900dd33681470a007445f7336d600f5cc4e670447ee0130706c14584a06589fafb018ecec1c18d9dd2f18f5d957127a8c8e1334fbc4bd04be5285649111a1435b00e42886806001e979d8dade1a842d7e7d419c387f2dff56f3d84c681b1ebe7fe004874db4ba37c4cb1a1ebed936cfe11c5ccccf435e340f34d448b201aafa02352fd693c1946825b08e59eccf820ce32b3f74db1e77f74ee8a8abaea4311cc72f9a334b0567c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4168eccc51ce84d7b878c9e1258524b02138d6b128147ec8a2012da0aeac9f72bdba9af78f1697e64b797468d4c37817749c4b39af191e220d696981b7b8966c11d587a6a1ea09a539e8f575c7a858cfd3f3edb179a2adae9f8c05e6e1721c228c1d8754b6f80752711367f8fde96e868164406f0c089c9db2fba7029a2f9c5819d48bd93f9e319d2c9f3d68f6f7f13de7b929d812709a4e30e7ec018e7257756772352414916c2a9fa1ed5fa8e37e5bbc8d3ab9ea9400eff4997afa2308025b7f5b0dc0ec2749749933cf9c72544a35d8ed10f7b7f643b8454fa9cbf922687c62729bdb3e9427d13def4a0e5a007442239c95790ce353c52544df602224435b7e14326f616a830e5567006953fb7cea9f4f90a03b04480a0bfa2ba25a97ea4a6d713ead248e7378b18d616bfdf40f71750bfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416bcafe83b0c9b4e83a9e1c20465d85619fd94b6554e0d5c8f52d5bfe0cd29b4ddd1451339aa7f243a93b7d11210e498fe88d07436baffd3275c2108c908fcb796efb1e202a487c72a2920ccb459c22a1666ea588d905060ed19f9f94b9ab8aaf87f04c8fe673467ae67156e09099ec301eeb4015dd865a16262f6c0366005faa1e15cd315e73127219041eba516ba6cf902418ffe9d8addbee9e12a79db0bdeb5419cc83fa426dbb9c006eac00370113a0f41a39cb73bff5d1c0b8dcfb29fbbb12e3c0400bb72c52afa3bec21db2cbba86ca430fd75ad0b6a460f6771437f96efeaab035863421aaf878c03a61c34b086220f732b1eae3c982ff2f62c00f576ab413cef2e394a8247f4e953b231da2d41d2594ebe365c512eeead8cc0be9a8dc813ead248e7378b18d616bfdf40f71750bfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4167a59f404a03f7f513aa707f28f0ac118da5a0874b778ea93fd19080b79eb5486b5bd370ffeb07d90415b4400b590681a88d07436baffd3275c2108c908fcb796efb1e202a487c72a2920ccb459c22a1666ea588d905060ed19f9f94b9ab8aaf87f04c8fe673467ae67156e09099ec301eeb4015dd865a16262f6c0366005faa1e15cd315e73127219041eba516ba6cf902418ffe9d8addbee9e12a79db0bdeb5419cc83fa426dbb9c006eac00370113a0f41a39cb73bff5d1c0b8dcfb29fbbb12e3c0400bb72c52afa3bec21db2cbba86ca430fd75ad0b6a460f6771437f96efeaab035863421aaf878c03a61c34b086220f732b1eae3c982ff2f62c00f576ab413cef2e394a8247f4e953b231da2d41d2594ebe365c512eeead8cc0be9a8dc813ead248e7378b18d616bfdf40f71750bfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4160d7041b32d9feadb4ebf7534b2f9c8b544eeb52b9d09b888e6348616014240dc7727108ee05690d0ce9d31d8adebb55e41804a7d795026a5f374a228e5baa87fe25335ca44d4beb1684d26990255699dc74aba0d1f1c9a8ef0da449679795537d2e6709db02b82721838e18c10cb4f163d2af7aa4a90cab3ac91e52d11b505c8be6d6d213ba336b3b19be8b43650dc8c63aff754cabf5381487d5eb1400174ada9d5ce9b8540bd9d0a79b6f54da7b52977c67ac6cb354ccaf754bc6d89743134ade74624c82f28634c55f9b3fbb2622d330417b3b7a0ea3a6966642301fe38fff9cf7fafea0aad0b587cbc66a997534055acfc3e896c1687f416179ce8189901a28892672bc9b229f3555a4c6a9b5426cf852dc3c78659d2a0472f496bf1b0986f94f251d28d300d2e3065f07ba61d1d89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb164fe2c3e0fd6c201aa2b4c0b9140331c191385855a6c9192755274f7aa4598bf94447ecacd053f5c149150b683f0840022adb37b5071e71f59cc25318ee0c5e36d80b8fc36dad967f395d07ed9e7de971d8b345c515d08fa1b93d9d5dd08fb59ecc3cecdc701a14a75483839707024c71f575fde964bd06f100c6d2be57e611963e07e9d1adfee69e4504d4f53faf0f740e114095e949355462396e682ec97df69963b3b72fab21d32ec49c5bacad92fade2a654492cf17143e5840bb4953043ffffd45be579bebc26fcd230b9ed243abd9bc2a788e9c639f0c4681e687c70425676d4954d68e60d67a1397f0e366bbbf6970f66843e3f9724837eea1c95d7b6a80d14e5c1c4226db60566c270e1794c5db937ffb1542081ceac27f5a7775640c16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1669f10cd73306bd8502bda6bf95270c2091385855a6c9192755274f7aa4598bf94447ecacd053f5c149150b683f0840022adb37b5071e71f59cc25318ee0c5e36d80b8fc36dad967f395d07ed9e7de971d8b345c515d08fa1b93d9d5dd08fb59ecc3cecdc701a14a75483839707024c71f575fde964bd06f100c6d2be57e611963e07e9d1adfee69e4504d4f53faf0f740e114095e949355462396e682ec97df69963b3b72fab21d32ec49c5bacad92fade2a654492cf17143e5840bb4953043ffffd45be579bebc26fcd230b9ed243abd9bc2a788e9c639f0c4681e687c70425676d4954d68e60d67a1397f0e366bbbf6970f66843e3f9724837eea1c95d7b6a80d14e5c1c4226db60566c270e1794c5db937ffb1542081ceac27f5a7775640c16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb160e11b8f0e6957d6146b58d99e4830c7e1aa964f35ce8c0f47d18693bca5ef2f4b4b170d4afb5d59b0e69a4408f36007d0550de25a01d912a175a9c40e26c1cd4dca3e3b7694787182c0dd5643cc242d1522972368a0218ce559e76a8999d172a1b3bceee36eaeba8139191bbbac305beedcabf100c66b1201082179fdf673c0e0ee49120cbb40d0bf9becb744be591977fb0108f909b1ab9c4acc93a0a5755963e274b395c4896d3b62b5115f0321b01d1912373577b28d16fc0b065550987682d4aa6240bb0796c4d9def92718b4e59d9c67e3cec00c8097f9ec6edfb1f6583fc92ac1458177a561f82bd03b6fd1c516970f66843e3f9724837eea1c95d7b6a80d14e5c1c4226db60566c270e1794c5db937ffb1542081ceac27f5a7775640c16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb161ec3865f1f365da24fc28dc45c8d038945949b2a25dacf9869a014e85afd3c49f873024b0ccb2874333df1e78a9316361e557dd62e83989b3aedaebadfef57d59770883d214a34470f2592ca2fe850d96b24b707c2cfe29775bf8f042db9570a9b1c612bba6a8f2ba9572a792f25334b9a5585ac657e8cf6e9810aaebd96a6beedf85000eecbdfee55ad9a0488542e4b2c20f61feed9da006483385ed08e22ed2b3faf3bff7a93073c9aebc41f5039386dc77fd5feec114f9dfbe6eeb28f9a5bfe4fa1388d8d51ee3db71feb1a62e37f8d2e2e36308d9b8af2d646f005c4328eed1d3c5243b43e35e4a35e57ba78bafe32167b37fdbd227e67c3c1b81f60dce0d605c755f3043ddf02367003ef1a69ce4e967776d8afa8d877207b445bc8d10f58f8581c29d6bd9badee09b84991852da2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb166cb8ca8b93835347f7019efe0a85bf8745949b2a25dacf9869a014e85afd3c49f873024b0ccb2874333df1e78a9316361e557dd62e83989b3aedaebadfef57d59770883d214a34470f2592ca2fe850d96b24b707c2cfe29775bf8f042db9570a9b1c612bba6a8f2ba9572a792f25334b9a5585ac657e8cf6e9810aaebd96a6beedf85000eecbdfee55ad9a0488542e4b2c20f61feed9da006483385ed08e22ed2b3faf3bff7a93073c9aebc41f5039386dc77fd5feec114f9dfbe6eeb28f9a5bfe4fa1388d8d51ee3db71feb1a62e37f8d2e2e36308d9b8af2d646f005c4328eed1d3c5243b43e35e4a35e57ba78bafe32167b37fdbd227e67c3c1b81f60dce0d605c755f3043ddf02367003ef1a69ce4e967776d8afa8d877207b445bc8d10f58f8581c29d6bd9badee09b84991852da2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb169a66e1de5eacb849e257008a6c45448131ee964e70765ab92680ed84196541a78bff3830116c9e02a5cc418fc7268494384ee1531018c2c4203d88f686c5a8ca3d5165faf91621f2b9c95616891cfc37f864477226966a646880fed72b4745ea44e6d2ce61a0e70704085aa8d2b73feebd45434fbd26f66b4b26530a82a8e0fbe42dc857906a83b223c14a95fd56e62b0623a5181520adead4c5e7f4726bd773d8449c811a33af1a90ee294bf762864e49bc8b5a2e2f0b1609752eea0a56aa35a862996ff30d09370e8c65f5da329c98289e0c61f78ce6f2c971c87a0bbf2103b72ceebcf7c4681592bb6c5467f270e31070ddacb071ceff6d2d2d51fb85f6e4387b3c66577d13d5f45b286c1482d970f6406bf1b06319eeb805f961877baed06220cf483ee7137353761014ae85132fbaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c41650df32398db3b3fa7d7a2f8ebd4fd58d6dee5828ce3b6c1458376b49bbd48104b0b2c84b9bebc136aba69d403b7ac7801fe9d91876cc3814d711bca567c624f9fd459ed69b8cafffec6562ea3e1fd6d73864cb56b97c5c9644496828746c0b284240d09ad6bad43daa790e41a963463d0c885f1df7138a270cd17686e82017dc2e5b989a776587a5490f8908f9dd163f3545fa460749c9492bb8e6c7e9335e30a84f49edc53de17928c7fe577cf4191c690e73a31b76bd6331bc84806813c9dff6d15cc402ee4187b55c192fc9ea4f5296dd457beb86ae8fdfe267456ba191cfb9998bfc946013aba937de843d3f49ef1787e7af2cbf087e4d9ebbbedd2bbbaf25e4517de334d5668d242bc9864a20ceb8d4dd7835d62f358ee6afcd81cd9ae082dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416830e8248e7307021c7d9850fbbe4c7f3429b6001034e4ec21714d4ed8a80e67ea9fd2a5e89fed5e34cb77e7b3b9583271fe9d91876cc3814d711bca567c624f9fd459ed69b8cafffec6562ea3e1fd6d73864cb56b97c5c9644496828746c0b284240d09ad6bad43daa790e41a963463d0c885f1df7138a270cd17686e82017dc2e5b989a776587a5490f8908f9dd163f3545fa460749c9492bb8e6c7e9335e30a84f49edc53de17928c7fe577cf4191c690e73a31b76bd6331bc84806813c9dff6d15cc402ee4187b55c192fc9ea4f5296dd457beb86ae8fdfe267456ba191cfb9998bfc946013aba937de843d3f49ef1787e7af2cbf087e4d9ebbbedd2bbbaf25e4517de334d5668d242bc9864a20ceb8d4dd7835d62f358ee6afcd81cd9ae082dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4162781291d22094df13d72166047b8abdf5a0c2b57a9f7a14089bdb385c02eb4b48b5612403a673b0e8a4f382c7fc5bf59bd6e64956aa38cb11cf9a6297558ba963635bdbe33bcd1ef4e8752b75bb0a6c4558b836ec9346713b573abfd2b843d571bfd616168a78a69044f19dc837ca2675b650765e04639aef2837425706caf128afe1951c6f3e9195f13f789d1e7e18266dcd45d9fc11cba28a506dfa62ac61251a4873b5fb3a1c7fd70029828ab7daca4cc487afe2b470c2b1bbd5293eff1709ccc8087779c963b377631a2488c0f8ce6d8fedb58f48ce5d430b3349727788f63a61194ccaf9b04f4231e85814a0653faa366d9fd6d391942b5d1e32dce2334b977b0e33b8766e2dd46d9dd347ec06f61f78b81f89ec2fffd912a297bf555a96f94f251d28d300d2e3065f07ba61d1d89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb169570398cf26767c6617abd67baa6978f0ea6e1e52a54b1acf0f80d64c0b14e345187c3627fc49440d9f9345a2f71f60f5e710f6ca7eb4aec0406ff1857b57ede958283fb77a102faa638f6af49a8db53e5d486898440c99f149ba7b331ea564fb8ac1e0a05d379aee366d3c382b35176af09112285fe2a2a0c939ccc1226eb8bb78f284787931740de93e41f7b08101bdc788a3e59313dd1f440bd19b0d5e994afa706580ebe735f5448058aac36d429eb7ec3e55b29a3e952b328f914f15980c83701d84cea8170f5e5fc8ed6550681927591f8e66629c80dc06c3e45f531a336be872e5e7378e1eadd12293afee9c2d53cd1e07e30d6c01c4f20bd1b90925ac717d4ed6aaa99ea1d572c0b4c32415bb2cbfe61c201fa00497c7105863f05e6790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1676cd74623742deb15a82c6daaea3bfbbc217176ee115a6ce54f2adc67a88f3149c828d76461a19a183b2e28b9f9b4078261742f1629dd03944768f59e50e3249958283fb77a102faa638f6af49a8db53e5d486898440c99f149ba7b331ea564fb8ac1e0a05d379aee366d3c382b35176af09112285fe2a2a0c939ccc1226eb8bb78f284787931740de93e41f7b08101bdc788a3e59313dd1f440bd19b0d5e994afa706580ebe735f5448058aac36d429eb7ec3e55b29a3e952b328f914f15980c83701d84cea8170f5e5fc8ed6550681927591f8e66629c80dc06c3e45f531a336be872e5e7378e1eadd12293afee9c2d53cd1e07e30d6c01c4f20bd1b90925ac717d4ed6aaa99ea1d572c0b4c32415bb2cbfe61c201fa00497c7105863f05e6790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb160c007243b2b93856e0516ac79ac178c22576dad252b81c5a43a1b3e4c33217ddcca717fb22a38f0cbcff2b7c82797aebda5972e53e39b69d24d65bb1fdfa6d668d0ca4fa3fc5f4889415bfc74121adb503c148a2902a364f8d293a89abbf02799b8ff426c8a28a277397d6b53240a4617528b807017ca75cf7979b8a5c98ea49443ae311a3a9ad9ed6fa62874b064b518f1f21369db3450c27f46ad3a628b982d89d300733daab3e9b80888760be04326a5c325e4d6563a437843624e98e3c5e294f07b2e3555ab569fba39c7f61aa52d4521203c144db95f9398cb8af8cb72e29fe24e6789fa8ff90736cf8536a4475751ec40d3b6cac94cc4b601c21e6664367f32631c4a645972d8f7fa19bea1cd7535b272c101f94871be988774568dbdae6e1711f5af0f03c6d2054ec4c2c820ed8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1667e9cf14937c26e053e73600de59809e5a99dadad34e1186270f146fbfed75cfa9afa882d4fe9eb6025db18e6efd5fcecd817e04a4465592eb85aaf2a26710833ff981477b104ff2a835df72930121e1af3aaad21b1afc8594e9a0b0e89b1f5112144cf9a4a319ba3e1eac4d907483420168b32dac5f47f3eec12adcc0ebc4b26523888d1c53d88d7ebcfdfd1c431adc4c8773af2eb45f6fa402462ec67d33eab98bd04699cdee9949f4889adf0ac750b9f79602729b4d2a7eb503f89fca1b2e17a77e35e89697da46be0c2048f4d4579d02f6182684a83e1699bbfead149a3aeb58e8b2b065e7f861a671377f5a6cf7305f70a1fa2b0dc5861eff5a8c2eac4325e4517de334d5668d242bc9864a20ceb8d4dd7835d62f358ee6afcd81cd9ae082dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4161ad86602eed2578692ac82eecef25bf02550ffdc2121a2e07bd86fffb11737eaa9afa882d4fe9eb6025db18e6efd5fcecd817e04a4465592eb85aaf2a26710833ff981477b104ff2a835df72930121e1af3aaad21b1afc8594e9a0b0e89b1f5112144cf9a4a319ba3e1eac4d907483420168b32dac5f47f3eec12adcc0ebc4b26523888d1c53d88d7ebcfdfd1c431adc4c8773af2eb45f6fa402462ec67d33eab98bd04699cdee9949f4889adf0ac750b9f79602729b4d2a7eb503f89fca1b2e17a77e35e89697da46be0c2048f4d4579d02f6182684a83e1699bbfead149a3aeb58e8b2b065e7f861a671377f5a6cf7305f70a1fa2b0dc5861eff5a8c2eac4325e4517de334d5668d242bc9864a20ceb8d4dd7835d62f358ee6afcd81cd9ae082dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416187b645598ef5858e6c82fa21a183a3216d29bc0103edc2dc3456e17f0f07794b82c7db536bcbdb356aa491bdea0525693cbbd6f5fc90e331d055c02f0c21ba735c80d72f61ed486caf1ce54b42a19f41bb9fd42c511ecb7614f30abf0d6501ba324b7bfae6913c7da81231e73c8840dcb6d1e7de784578b02a68014e06392fa76e1a2db89bac0d4c725f3c4b861e717c80cebfdf97999c72dbb88822fe87b352b4f814cdd332cf467a382370a2ca04ad138dcc2e3c7ac729bd611fba72cd663bafbb3411ef06c7189c5043f81df8599369fa008d978e42cdd8e42e46d9cdf425894ac573a4affef9a4cc9b5a80a74ac9aab6617d2d7200a2e54dc198298a6eda28892672bc9b229f3555a4c6a9b5426cf852dc3c78659d2a0472f496bf1b0986f94f251d28d300d2e3065f07ba61d1d89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb1633b248c560674c7eed0dd01cc3c977698e8dd7ea5b94d1c693f6a8ac69fb71c26cbf1926dd265b847cc55c7fb6457a5227ccf3f0dbf3e03175696986ff4ff2081cde5656f10c3709b90b7691b724fbe3e2712bf1179778b03e4dd984a6ee61dd810fccbfee2189e53f238613cb57d1d14799bb46dfed0c58a10ed269496365bc4507b7703928a77145513b94f33af6614a3eed1a82f453ddd5465485c794bdc6564d6f8c5c0e5903a3cf93b9c80db33fcf778845d5c2834df875b8561da2bf9dc26c8b1439d8b4c98906e41285e5134055bc111a6eed157e93c72e4cd78cc7555e1a0c4b494c5f8c5f7ffb78def3c4c8413e48c66c7968b86e7b4d9f87a1d7379c2fad3008f74a952048dcc78568b56ff8c20c934d9203105804277a0347bf09f579ac03cf23f7701913e6de90dd0b04a2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16df99bbb64c124ae493da733d1e2599c450ef569df6072c83b0f7dba5ccdf77f4243757f943e5c098f9efd63621179a8d1156a359e907b0f53916ad702e45d59d235a761131a45ec80f400827e7135880e2712bf1179778b03e4dd984a6ee61dd810fccbfee2189e53f238613cb57d1d14799bb46dfed0c58a10ed269496365bc4507b7703928a77145513b94f33af6614a3eed1a82f453ddd5465485c794bdc6564d6f8c5c0e5903a3cf93b9c80db33fcf778845d5c2834df875b8561da2bf9dc26c8b1439d8b4c98906e41285e5134055bc111a6eed157e93c72e4cd78cc7555e1a0c4b494c5f8c5f7ffb78def3c4c8413e48c66c7968b86e7b4d9f87a1d7379c2fad3008f74a952048dcc78568b56ff8c20c934d9203105804277a0347bf09f579ac03cf23f7701913e6de90dd0b04a2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16c364deccc6caa9a451832d987607df0adba5ed1260747f974046cbc71e4317c1ac8c3e3e9def9b66c3522ac91addc363b3060e446f7595a9ea8f7feb519ece10f09031432aa52f6e0c080eac5fd74f1dffde2ad94151561e9cf2ce390eeea759018dd08ea6937677e566a397eb9db630d24cb14d129a2dbb8f5bdef03987bd60f1fd50f32b2b9f8f432f9cf9b48d6254c7e5103ed7e58b200dc01966865a33c83a770172f31ab4b4f5bee1e825427a4b7fadd63a83506e58107f9e1a19d8bbc73c38a1b9fc0700b0e5acb143002933f2911e182240f6bc1bd9554ae293258bf83aeeb9c18b2cb55b04a9b2205dd3cdc1153575d225aa26193cb233c20b45d2bab83638dcd407263ace7926f0325c6cd35721d802c3f45313de668784f2de619fc41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416d85553088207c143c54d52696a9e12e63844be1ff38f2bb49c5926d5efcae2fe25302e982a7197c4854f82b32ededf5fadafb82506d22c9696afdf7a88cf9bb19cb841515c7d5ca5de966292a8fa1a59f5f7793ef63a5c81165f04a1cf831095b6042358bfc46b24b7f4e63535887c8f95d5f81a6c6513d1c24311525678f0c00084418a5ec5a4da08cd577f68aef06e89369a9c1d161c01eda83c53588843f2b8114b947e1e0d19f69a6941697a1bb31fe01d4808e1d810db909941c6adc064a92e47824d4bb47f0828ea0c2d3b781e205951683f3bc2041582898f2fd74a142f8aa66e95a05044f9cbf7b8a83c9d2e68706d611462dd04e960e4cbaedb6b7744e97f69141d88784a2c9f3b88334a70c8043808a2cb1d5a04c94edc5d748bcf77de1f496eeb4525f602187cdb005a7e89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16c3cce88697f0eb434cc0278f4425bc453844be1ff38f2bb49c5926d5efcae2fe25302e982a7197c4854f82b32ededf5fadafb82506d22c9696afdf7a88cf9bb19cb841515c7d5ca5de966292a8fa1a59f5f7793ef63a5c81165f04a1cf831095b6042358bfc46b24b7f4e63535887c8f95d5f81a6c6513d1c24311525678f0c00084418a5ec5a4da08cd577f68aef06e89369a9c1d161c01eda83c53588843f2b8114b947e1e0d19f69a6941697a1bb31fe01d4808e1d810db909941c6adc064a92e47824d4bb47f0828ea0c2d3b781e205951683f3bc2041582898f2fd74a142f8aa66e95a05044f9cbf7b8a83c9d2e68706d611462dd04e960e4cbaedb6b7744e97f69141d88784a2c9f3b88334a70c8043808a2cb1d5a04c94edc5d748bcf77de1f496eeb4525f602187cdb005a7e89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb160b52e54ea7396a3c91009d74551f6a875cffad9315853ffd90c685ce5d4cfdc73cd6589881701405ec84d9d47e4251aa4ab89f336549c633f2d280afebeee606f503aca2a9f103b0c2740088fde52f03910c8f329b908c4047deac30d36f5e246e3ccf387b2832ba93879d4d2e35787f9bed9aae7da5a21874dcaa9c1da746379d0da90ec4329f374f709e1a7a450c289120f68617d557db0ce9e0048252a8bc84a30a965a2cf7c8f50cc84d2e2eae27c923a2cd5f025cf852d76990131d5110a3a6c197c270af7b689f54d466ef4f1a0ee7bcca5e161984b252e21ac6c7ee88a3290ad6840f00f33003da297ba05c998ffe96df6328234f9861ae3396f88a8170af0cc83585182531c3c4840f3a92cb61f78b81f89ec2fffd912a297bf555a96f94f251d28d300d2e3065f07ba61d1d89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16273e49903d8dc0f77bbf2d6e1b3f13f21ea5feb81e56d901ce41f3963b723ae21ef6ba8a6f92b6df7bf5524d127f82e7c42413224a7e54509b48d4c93263102f631ce2bca2bd9bdb01baf381359c2cf124cb724aaec9c9e84974fff7d3d662858ef521416e66549b08844cf799060b96d126b68512830f004363bc837487d8c32bed19fb96f7cc92f0723175b091d6f7b8b44a7b908e790ed4d306f1505df19bbc37937886fbe0f9ae365af170db1d6903757d4ace49dc9857e0a73a7bef6579715aed22ee317971eab052292315e5dd0076998b3a43cd17ed36ba395010621e92a43fe278c59b452d37259b750f0a13d53cd1e07e30d6c01c4f20bd1b90925ac717d4ed6aaa99ea1d572c0b4c32415bb2cbfe61c201fa00497c7105863f05e6790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16c3a8d5fe33ca85a61ad247036772a23d1ea5feb81e56d901ce41f3963b723ae21ef6ba8a6f92b6df7bf5524d127f82e7c42413224a7e54509b48d4c93263102f631ce2bca2bd9bdb01baf381359c2cf124cb724aaec9c9e84974fff7d3d662858ef521416e66549b08844cf799060b96d126b68512830f004363bc837487d8c32bed19fb96f7cc92f0723175b091d6f7b8b44a7b908e790ed4d306f1505df19bbc37937886fbe0f9ae365af170db1d6903757d4ace49dc9857e0a73a7bef6579715aed22ee317971eab052292315e5dd0076998b3a43cd17ed36ba395010621e92a43fe278c59b452d37259b750f0a13d53cd1e07e30d6c01c4f20bd1b90925ac717d4ed6aaa99ea1d572c0b4c32415bb2cbfe61c201fa00497c7105863f05e6790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb168e6e7de66340cb8b16f20b4704bae4021233bddfa4758fdcd0203ec09305bf251ded623232e873ff6a852b55be095dd3aef97864f2b31aaaa6b271c8b5ce32d96813be0eff83a1fb253187081441f5fd1b1cb4964ef8099cb463e7b13d8285e47908cae83f1cb218c124f82486fb0d5b8a1d31e8e24588dc2f5ff8b6694e24754e80eddb2fde710bba38d2779a927e5d0e961da0593932ae1382bb2c4050e2db4ee5933acc7b3bddd8711ee0ff7a3936037ae39c31a6ee36a4f81798e8c213d27dddbf719463f8f87c45e43f2b4c92c7e424f1ee4a24bd5b875271e06943225cde747764a7f823e6d3ff1f19e23349cdd8f8be083bb52597924f4e865132efb02d60f2308bee7f72569de9f27154a1d31b41314b2ab014109577cc7beecedfdcfd8cb56636f2bdc7d2a4f11d592e78809e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1634c8e28f7cc2d75fd03ca48554f33c190a87793113dd450c5b301dba1ab276e65b02cfdb3b431973478ffe69507e10dddb58f24924ce9a31772da05101cd2fb685af697111cb9a5d5d084b7e06d62efa4c2ae524d8d39698efece7efba228d4c3e7284ade86520b67feb190fa29a5a88e58b6cec4374f641f390c26e9fcec700568bf55b820969f3eb2566fb5a1c2af38f7f5580a2b9714738adac116f2d04379a3a272500125e944dd6c56f2c1a758114ffd65c7ee1ba21b9eb5e5b8873b074696d9d459876f37101dd7235f338dcababab066158145df3333708a8e90f555a483b8f8a462f61a6e0120c7a3c5b73d27747bce71f17b02feb60cc5ad9338d855ff46211118259c9b4f7807c501f6fbfe010afe0c54ae16877a8f7763ae52d2ac41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4162d732c718d6cff04df936cff92cec172f93d28ba7a905c3078a0f85c0ffbd24b5b02cfdb3b431973478ffe69507e10dddb58f24924ce9a31772da05101cd2fb685af697111cb9a5d5d084b7e06d62efa4c2ae524d8d39698efece7efba228d4c3e7284ade86520b67feb190fa29a5a88e58b6cec4374f641f390c26e9fcec700568bf55b820969f3eb2566fb5a1c2af38f7f5580a2b9714738adac116f2d04379a3a272500125e944dd6c56f2c1a758114ffd65c7ee1ba21b9eb5e5b8873b074696d9d459876f37101dd7235f338dcababab066158145df3333708a8e90f555a483b8f8a462f61a6e0120c7a3c5b73d27747bce71f17b02feb60cc5ad9338d855ff46211118259c9b4f7807c501f6fbfe010afe0c54ae16877a8f7763ae52d2ac41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416a9ea9cb722490bcad12d091fb31e63c6aa2bdbc26e2c1e4bec94f46573b6d6260e854962a5fcf55565a2e4a97043adc49a8ff20ab0c2037639eb7c35ed3bb12fd52f72fcf4642a1b7ff60681dbfad20e5e6208741ba509336d3715b6b709155aa5944719eb96b78a03b14b484515ba74f5853b2c9cd105bc08703d01f64fa57ff74e47c0b7a43881bcbb86660add3cee2e9c3fd60baf57859ac1f381ba8dc1622a50481005c1266626a0d6b70ab5767d05204f0681c61a66e36acb68f3b5f99f62c1f5dbd61eaa16a42a955501cd22703bd65a9c6fbead0f857de047acf35702fb51f908492e33b211539830ec4d811fc584cb7213f7649f12e27d4403f2f5055ff46211118259c9b4f7807c501f6fbfe010afe0c54ae16877a8f7763ae52d2ac41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416d361d53af65f3324ac9c382307e9b8d4d1065754c183b2aee139a13c809780d6a36af7e9f1216469b7cb3c6c9bbb741a6a765c83eabe6520ca686389a0f4175b97b79fc4aa003b2eaf15ae7f6034aa01d92a77a18c8c3ed85fb6b9a8dda78889aa718bff40642a2de72022ab35a07ff704a12b7008f2784c426afd8683aea0055d298a8ea775406d6f64407ad7f45ca41f4f9541203c13b254bfaadf2c84ac49b90570d46d53ff6d8c753fb4d6347e5b23afda772fc18f46ba122410072ec10beeac1a2c2e99d26a8b1a0361c4d158f592585e5d77b867bc091a58a8e6e4a60bb5a13e764b336d9b1637c4d2a76cbb30893983d47b447eb6f170b750927bcd444d5f2ded69604dabcdab7e10a2ace1facdfa6f2db7a0fe47fac5588be5c2abdf82dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416b6e10c7def86c73afa803ccf3f859d61189724c58d59d4253b91ed0700b2eba06304185e81007aa096a0ca85c4102d2c6a765c83eabe6520ca686389a0f4175b97b79fc4aa003b2eaf15ae7f6034aa01d92a77a18c8c3ed85fb6b9a8dda78889aa718bff40642a2de72022ab35a07ff704a12b7008f2784c426afd8683aea0055d298a8ea775406d6f64407ad7f45ca41f4f9541203c13b254bfaadf2c84ac49b90570d46d53ff6d8c753fb4d6347e5b23afda772fc18f46ba122410072ec10beeac1a2c2e99d26a8b1a0361c4d158f592585e5d77b867bc091a58a8e6e4a60bb5a13e764b336d9b1637c4d2a76cbb30893983d47b447eb6f170b750927bcd444d5f2ded69604dabcdab7e10a2ace1facdfa6f2db7a0fe47fac5588be5c2abdf82dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4168116c77c37a8451a7c53e548f4899aaa6b592f1519b0d54dfbb3406f07f63c38b1ba328321e011f266fc00c6a3d5fa9574d04f1499b93cd7c6bfa41980489519b1185131028bf9074501b9fcb16f65dab2fd45ccda3e735b995614fa34a5572750e196ef737a0110147d15bbbd83e1cea2bd81035a2693262fcffededcd83b86f0ded0971d76f89dc5691345f1dafae93a211fcb5b98cbafbee46905c4f5072975432a54b2be0f749020c10f97c796766d6bf5fa6e272c8ad8da574b31bf70d48319df2e72d6db55bba9495e49d1c24a52a677e73ab4892f1b2595ddbe40a84ab518efa79d5431984d7c3d9708903e888ffe96df6328234f9861ae3396f88a8170af0cc83585182531c3c4840f3a92cb61f78b81f89ec2fffd912a297bf555a96f94f251d28d300d2e3065f07ba61d1d89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb161cea41c799ece380cd4e6e9249fbc7a047e27f3afae14aa89f6e06972e2805b16eab5bf74cefc85d9a83b2240e98279a0657dd70a85cf721d4c8d15327198db101c3cd9d922f805df3a669c4838ce66062f4f3a8bb391b3d0418861f1f4cda0d2d144286f9d184d540bab272fd8d9af7e7932422ae8bfcfc1b96e61054f937346ac0727d1dbf65adeb2de7c539e2d2ec8136bdb64af25863ce4f9019e651342bac55cbe22f21ff5162c97c86b0f495680b86e65adc84baa5e4bd18129ad3f37b00a196b49023baec74b42ad7c1da709b7468be4a6fe9ec1087ac7b1794086929579ab3dca5b25f9be572cde40de8b638668ea7b328e4983d0ba6ae119cd1eddec0b15d91b437f0561ad3004e6c9a4275f6406bf1b06319eeb805f961877baed06220cf483ee7137353761014ae85132fbaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c41657b3888906d2d99f79a5f7609d033c9f86829909b4f5e7b68cdd508dc682631b9c400d84139e083496d4ae772a15a5f80fb486ccb6de7ccc63f1bb0d859067a6926a51e41e92835ab93ad1f5d5e98e68b5af1d21ed8541d30f02ff230502245547843c2ff0b14729f3a58b8ee047530fa76c1f57fa2347d4d4fa158e552f7e71a2121bc064e1938f4fdb4d2997aec3c38136bdb64af25863ce4f9019e651342bac55cbe22f21ff5162c97c86b0f495680b86e65adc84baa5e4bd18129ad3f37b00a196b49023baec74b42ad7c1da709b7468be4a6fe9ec1087ac7b1794086929579ab3dca5b25f9be572cde40de8b638668ea7b328e4983d0ba6ae119cd1eddec0b15d91b437f0561ad3004e6c9a4275f6406bf1b06319eeb805f961877baed06220cf483ee7137353761014ae85132fbaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4162565a0a4db451cab3aaee4136dd2451cf803a604fa0d6b7b04fab7fce4f1b950fa5c2a4683545ef3a8185ade286180f2a5ed3674684d8aa30b4dc0a3c986dc55c268bcef09ae15fa3775a2148d9a8134caf72162a0c02689e0ebb43c66f5be3c3a31751882748f5c5de3c4c43e0569cae532a44a610703f5bb14dde2774d67f68e00fceb919ca28a226223ab13697640266231b89f47698566f0bfdf9fbde7a518bac8bfd1230d8458c8aaced40673737d7fe7f7dd1ed57967ff9fd7317780012ce7f01f41a80c929d325a8a3104314155af2c40ba8514326aa1e6fae0966537579ab3dca5b25f9be572cde40de8b638668ea7b328e4983d0ba6ae119cd1eddec0b15d91b437f0561ad3004e6c9a4275f6406bf1b06319eeb805f961877baed06220cf483ee7137353761014ae85132fbaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416e9590733d023574e4c889c6aecf5297ea3175cf4eb996b441f7379c3e0b61ae49c75996e88bce7a62bff7350a53bba24a230c29d20bacf4a954eb3822919aa48a54531e847173ea356ff372a3d2d72bbaf293d3a1f26eacdabb9290f9b7c0847397b763cf3ffcabe4aed5616deae392143768d95744c1d6d29005a105474535d908af2309dd4bfabe771b93b408cb65127cac21cb795493c389b4be65c4327fdb97e52c159c8c9ae98afb5e4cb3bd8fdd9966207c38332fccc098513cf6cd08043b38ac20058e9d4fe495a5b45a6a54e70e2490a21734cab93e8791068b2b97f01b80fad75c950e96d62228048675dd7b26dc2d908ed578ff640e1e59d13ac979badd8cd3121d3203d99e6b783d39d409661f933dceb64d4e5cec789680c3b9148e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416a3a035ffb8e702436851545e86f7664ca3175cf4eb996b441f7379c3e0b61ae49c75996e88bce7a62bff7350a53bba24a230c29d20bacf4a954eb3822919aa48a54531e847173ea356ff372a3d2d72bbaf293d3a1f26eacdabb9290f9b7c0847397b763cf3ffcabe4aed5616deae392143768d95744c1d6d29005a105474535d908af2309dd4bfabe771b93b408cb65127cac21cb795493c389b4be65c4327fdb97e52c159c8c9ae98afb5e4cb3bd8fdd9966207c38332fccc098513cf6cd08043b38ac20058e9d4fe495a5b45a6a54e70e2490a21734cab93e8791068b2b97f01b80fad75c950e96d62228048675dd7b26dc2d908ed578ff640e1e59d13ac979badd8cd3121d3203d99e6b783d39d409661f933dceb64d4e5cec789680c3b9148e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4162de37fe805e6eb11fe17c1dccf708bf39761e8d8f6e67f3511d34dfc07b1dcfeb77490f4e7f65129dcaa116edcf483ef46fa7458f6ce3bb7470f4b71f1e13f9d5e9be1bfbe44a415393708fd1a76cf6d160c5fc8883267b70b8f89a4bc3259adfb933961bf56ca46339d3993ccf7e1c3fe2ad2ff776fa46e73e9b386b366362ddd3f30b10ddcbdc8d4c42feec2facecb596d7def87ec4aea366e6e9b053621748bb06905a08a4e8d175f4f92b54e83457d4c97740eeed8daa4fcbb423c9beecfb1611b01d03bc1725d71130b6a9ddaca54d0930502a791f5b5063e88c221fb2505ec0a62b1bc6efe9fede4be01a511e08e9846d2673b2ac48fa48b37638e99b4142aa7674d60f5b81d415dc4013eca4471d89e3150fbadf81a8d31ca92d6b35848e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4160a86be420fca61076671181da2dc72f2765057c129080ccedc2b4e1d7deb9d764b2eecbaa08b833291deb0a37f1a1fb7245546e5dff3bd71f1e0cf8f925d9912fddaf6f4890ed3cce86a758781abb41a3084175f8522da740782801bf1d6fecaf4c12661a6d3f011805379c8f2d1c65136daae120fb02fd579c15c0e472005bd8269f6b404e6729c86633f91596d2ff7fdf5aec695c6445825901c42d7f5ec49a7a53814cab424eb1ca5f5e070629293aca12a5660c145bdb7cf4768072f25cf7f3748bcdc18749652c1aa43c70dd7f5ffbbf41edb6f6a3bb9d1208651b391a17058707dcb5d10e133c41b272193f2ab171cdf7711b74778b1de2aee23243b3330f92e0355660bac1213d58192e5d4c4e010afe0c54ae16877a8f7763ae52d2ac41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416188fac183c44f6e5aa68dcb1b4326334ee450cb369a84225559ced2def2013e74b2eecbaa08b833291deb0a37f1a1fb7245546e5dff3bd71f1e0cf8f925d9912fddaf6f4890ed3cce86a758781abb41a3084175f8522da740782801bf1d6fecaf4c12661a6d3f011805379c8f2d1c65136daae120fb02fd579c15c0e472005bd8269f6b404e6729c86633f91596d2ff7fdf5aec695c6445825901c42d7f5ec49a7a53814cab424eb1ca5f5e070629293aca12a5660c145bdb7cf4768072f25cf7f3748bcdc18749652c1aa43c70dd7f5ffbbf41edb6f6a3bb9d1208651b391a17058707dcb5d10e133c41b272193f2ab171cdf7711b74778b1de2aee23243b3330f92e0355660bac1213d58192e5d4c4e010afe0c54ae16877a8f7763ae52d2ac41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4161cae9243dd21264d5abb1eb1d9b6d8eec2928a9315210237b85bdae4c065ae2d97839aeb981470018aeb98fbab1e923307de7d7545ad456e3adb0c97e85083d671d0d0cf21efa8c783313db29f449a2538ee4a844aa74de78af9c0bd0510c06719851a1f2e1f864c18b6b8bc090a6b8d56db09f4a50ada45e136f52d6988f10d6818cc36518ffce8bbf9f79d692ec490c74721e8026c8a696ba2cc2aaf86814a2fb9d988113a805a982756c851149af008f2d2922f2b5e6433cf6eea977749e7373cad5e9155ef4745561df263e6814dffbbf41edb6f6a3bb9d1208651b391a17058707dcb5d10e133c41b272193f2ab171cdf7711b74778b1de2aee23243b3330f92e0355660bac1213d58192e5d4c4e010afe0c54ae16877a8f7763ae52d2ac41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416ee25652a669e7c650fba9e9adae51e6a5017383146bba685b16a94f67038e123f542d19016c73b6d5c4cbf20358dd8a722821fe14d4bf8ccdbcfb6d0f5b4206aa22e9a1045fd29d04321536276e7629370a2f7bfc1dfb6c6216318ba9f7fdefd23fe9d2cab920638b60e64a83a120261464d73fcf7e346f1c829701c40fa49e17c4be28375beb2787efb09a4a923cf4b577a9594f57ee585ac330a75be53d3e83b62b780eea8ff2ea42f09c525d3b6b8873be6bb1e98341560b5ae73ab818462103427060203a45f44c9bddd16c0b5376d7b29d5118e97b41fbb7fa0c2b810e26084edd819a2c19ac9f45adc9faa78b48797a56e5b8b7ac152378048eafd17fcf0b9542a6780744ac15a6977e2533c38f90a03b04480a0bfa2ba25a97ea4a6d713ead248e7378b18d616bfdf40f71750bfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4168182f5649b052ca96830b6c1420df7775017383146bba685b16a94f67038e123f542d19016c73b6d5c4cbf20358dd8a722821fe14d4bf8ccdbcfb6d0f5b4206aa22e9a1045fd29d04321536276e7629370a2f7bfc1dfb6c6216318ba9f7fdefd23fe9d2cab920638b60e64a83a120261464d73fcf7e346f1c829701c40fa49e17c4be28375beb2787efb09a4a923cf4b577a9594f57ee585ac330a75be53d3e83b62b780eea8ff2ea42f09c525d3b6b8873be6bb1e98341560b5ae73ab818462103427060203a45f44c9bddd16c0b5376d7b29d5118e97b41fbb7fa0c2b810e26084edd819a2c19ac9f45adc9faa78b48797a56e5b8b7ac152378048eafd17fcf0b9542a6780744ac15a6977e2533c38f90a03b04480a0bfa2ba25a97ea4a6d713ead248e7378b18d616bfdf40f71750bfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416d2822b9c4474cc9cdaf321ae5fc687507ac64055d780037a20cd4126adf01083f73103c338b7415f6d83e68b9ddff8909509a118f7742b3a6a15f6bec36de3db8b9329c38af1c40c18294a416f8525fe40150d73f237b48893d5a2fa0a12740a20b8bfe80fdef9c2c4c810d522fbb23d6b53aa68445c006f0e09c445ed5dc2f21636c67081bbd888a977a957caa2620677c708aca061f7dab8c1d3dfc652be9fed656b5769752b1460a741b7daa3ac748d6c1156cf7c2d01f8a56dc1199f6017aff336a75a55cd25c839951995e864515d65406f1188d73109d25c90981162bf4b1586b34fff7423bfe415c7cbb2d3b1c95790ce353c52544df602224435b7e14326f616a830e5567006953fb7cea9f4f90a03b04480a0bfa2ba25a97ea4a6d713ead248e7378b18d616bfdf40f71750bfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4166bf3417105df1a8387aa88136f07f42db12ed7dd6378764b6e30da8e5408fd3bd364904ca569a4d419bfa0f57e61e192c31758248f6e610840758904bc0f7e5c44f1528bd02eec618642a5b17a03c3f2911ff9c9efaf4801b4766bda46aec53984d2ddb32f853b51c535830facd7dbddf1f4f3b81b3ae3d863ca79d4ef5cc1553831e27d90b5f8e68e99788d4f70a2107f52f7b4cb71e6e9c193f844e75999128f018dabb87a55f8ac4608d52e520a28ab57f2315119542fa3aba5f27683bbbc80437b50f8a3fbaacd075078d8b1854c33b4d4f5ab4e70e33f219fcd62caa41b22c89db72e9ed65b4f87660a2ff4bffb0381cc15ab0b135ad24691f166239b0c3c1aa3dd28166e1ae56d46214940d61637b98c2fb1c2d63993afa1f2be32d04ddd4b321eb4d8f9c7a5a96092c8d69e7fbfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4161cd11eb22dee7160789eb797de971f6d14d7245033c271b29bbe883d5f0e6432d364904ca569a4d419bfa0f57e61e192c31758248f6e610840758904bc0f7e5c44f1528bd02eec618642a5b17a03c3f2911ff9c9efaf4801b4766bda46aec53984d2ddb32f853b51c535830facd7dbddf1f4f3b81b3ae3d863ca79d4ef5cc1553831e27d90b5f8e68e99788d4f70a2107f52f7b4cb71e6e9c193f844e75999128f018dabb87a55f8ac4608d52e520a28ab57f2315119542fa3aba5f27683bbbc80437b50f8a3fbaacd075078d8b1854c33b4d4f5ab4e70e33f219fcd62caa41b22c89db72e9ed65b4f87660a2ff4bffb0381cc15ab0b135ad24691f166239b0c3c1aa3dd28166e1ae56d46214940d61637b98c2fb1c2d63993afa1f2be32d04ddd4b321eb4d8f9c7a5a96092c8d69e7fbfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c41607f386fe2db8ad747153c8408a836eb03b1a50c2e1f713bee899eb28b690544a2300ac300c67d2e406927fd7f8f576faf125c4c28b35d67e9d9c6eca969e36ec52e9c9b96e6252c7b89b70364ad674f295b2f877cdf54b27ff2b241f0860a866ce540b800d2b34629a8919bf426460b95ba8f9ed9e66f58a23a6d195cd1c0e13d918e5d4454e9f345ad7bd2fc0d81e169be41f88821b28835c2f8606b13edf9565495a7c999a9c62bc3990e754f6f5e52159805913189e7e7cf58d1a9328ad24c2511f2b59a32574812537e9e4c9ae725fb0f163fdad655191946e6a1332dbc1969262c17a6ba3a6465e35b1d04fb38ccb4a45ef73fd935e533d08fb316c836bc3b264f28401b132fae8a8d55525a0414e967776d8afa8d877207b445bc8d10f58f8581c29d6bd9badee09b84991852da2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16e81fa1fdf587c5f56210c43630de3342f0b6c2a6bf518bce18f653d81d4d17cbf968ac7e984151ea8fc7c6fe1848f3cab7bc7a0a447c6f970df1b0181bfaf26c5294ecab63138caf8101e67760405589f592882a72d8ff82af1db9b3e6819047c3d22f13e47ee56c9dcbb2f4fed10ef2391433eb5a64e1cd55d84c35e118cd5510c95ab48a859077d86bbfabadc3f11aecb8c6c6aaf18c9e19534a021bfc3a5baab9181369b5f5f6eacfc48c584e2ce6728776e025ba6c6516b2589376038e82ec2f796c45040722552f766097dcd0d81c2d7442f6d6329fd5c31e91f8209dd9a3b54d31ccfd2dc698a7d4102b2bafc676c6f1f6a483184d67da40b5406fc9f63fd98bebc1bedbc83591e18be409a14c276721e6084668df533084cf36d12a24f579ac03cf23f7701913e6de90dd0b04a2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb164cb95cd164556d012bcdeeee1da6c2271414583b1b91fb5356547dfe7f9732c7f968ac7e984151ea8fc7c6fe1848f3cab7bc7a0a447c6f970df1b0181bfaf26c5294ecab63138caf8101e67760405589f592882a72d8ff82af1db9b3e6819047c3d22f13e47ee56c9dcbb2f4fed10ef2391433eb5a64e1cd55d84c35e118cd5510c95ab48a859077d86bbfabadc3f11aecb8c6c6aaf18c9e19534a021bfc3a5baab9181369b5f5f6eacfc48c584e2ce6728776e025ba6c6516b2589376038e82ec2f796c45040722552f766097dcd0d81c2d7442f6d6329fd5c31e91f8209dd9a3b54d31ccfd2dc698a7d4102b2bafc676c6f1f6a483184d67da40b5406fc9f63fd98bebc1bedbc83591e18be409a14c276721e6084668df533084cf36d12a24f579ac03cf23f7701913e6de90dd0b04a2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16f97f2db33c0b83b3eab2f259c0239b338debb6f85b590e59e3d71f769553e9a48729cd0ac19c287f44ee490c7f28e8c56dfdb8e4801a31a73673bdc0b55bb2b83b609289ef74f0429e1a42703f981a698c833a3ccb617595508eca01db4e38a1dddcd17f6f6d954897a5cc4ca71c3dec1d125f16527e312adc431eabb9d1b89f7fd6073b249d610df79522ef0feb1df50da6dbb989dc05d56f3719ec6655eebd267471c552b0d760b2e08b2a8130915108e57db2ef3b2d7c26f1d18a638d17a68d299f1234f853596187ede2e18d412ee9dbb6cadbcba81859e10b863255edc1d226bd16b0b911a5c5b34f4b3659687b07d655946c047662844cca9adedeb36ca3a3871c6b1b49b640482e97e5ec6d61432d11633ced1a5523ecd3dd5ecfe73713a152183991c8448792b4b2616b663267c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4164a49c411875cda247939df1a47f15feb6af70e032ede260f854da85f9f0963300b40d385a1f8d8511c255cafc92ae12497b1f81b3658a39bbfee29f9f76207d67ef0c34a0e11026fd6cb5decffaa6f7e5fa148bc93763e926912419dbdf6faeeee2b10e1349d729ee3882580f40d12609ddbeebd23a277033667dd03f1a9151aea1ae28423dc6ad5cddb149c21f31c6df6ce1b07299cbd83086bdf21325d1e99451e26626550644d8999eeb605c78bbd04f653a3c1ba4bd7bfe753ffa47fcbb0c9d68a5fe9b6329f32eb8b139e76db9a05df3d33f557deef1c3e915ee8f0c655781fae3d652717e5dfb81666cbdf8345751ec40d3b6cac94cc4b601c21e6664367f32631c4a645972d8f7fa19bea1cd7535b272c101f94871be988774568dbdae6e1711f5af0f03c6d2054ec4c2c820ed8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16dc764512d0628cae2a415f9af0d9afc46af70e032ede260f854da85f9f0963300b40d385a1f8d8511c255cafc92ae12497b1f81b3658a39bbfee29f9f76207d67ef0c34a0e11026fd6cb5decffaa6f7e5fa148bc93763e926912419dbdf6faeeee2b10e1349d729ee3882580f40d12609ddbeebd23a277033667dd03f1a9151aea1ae28423dc6ad5cddb149c21f31c6df6ce1b07299cbd83086bdf21325d1e99451e26626550644d8999eeb605c78bbd04f653a3c1ba4bd7bfe753ffa47fcbb0c9d68a5fe9b6329f32eb8b139e76db9a05df3d33f557deef1c3e915ee8f0c655781fae3d652717e5dfb81666cbdf8345751ec40d3b6cac94cc4b601c21e6664367f32631c4a645972d8f7fa19bea1cd7535b272c101f94871be988774568dbdae6e1711f5af0f03c6d2054ec4c2c820ed8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16f7264a15de6d89d2f725a8e3c1645cc69d3bb728dc0a56f5638d620d067852ea3c59f060d8ef5c2e16d767dbfecb95503d75a6f157eef9f958308fbd8cc5beffa8da1c30975986550365ec85316778f27bcf94b490612114b867f15a24fc0e009e40a65894ea8e2ba3dc6417f3c3311ef3db97df8bdba63c2c819c49cfca1641ee6056ff5e501784f4175b0a23e5d4efab410300f663557f8f0c2dfd76e720e2fe82ef1b7d81af123acd8fc589c35333f4cbf236fbdf6a133e1ce9369a0cc962c1d482206dafa8b5ce1b5ed51558ef3a7f1e491b355338e818474e0064eee7c7b6943412335e4f88b1c29a53d5e76186c584cb7213f7649f12e27d4403f2f5055ff46211118259c9b4f7807c501f6fbfe010afe0c54ae16877a8f7763ae52d2ac41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4162a8f358d0a51b482952c52b590827c43d808763d1b158d2f3800098c3c46af4df7eac8e03eec96838423fee7d37307d39e2423fd38c8b94d3a816e5153b99b8552f5ef5ce3299b3792eb72cff04fa900940d566ce6a1f64cbd1c3d24f4db71d7b13a6a623194a784756003016daf18520a8773284c4ce1ade9087fed174e0e75eefc62aeda5c140d368b5ad6c9cb21231b76e80b4f0f9c6b4529d514faac54e6833c04819a3eca4b47cdad7c483c091bbcb663ba53c7818a37b15ae6eb0c0ed2a29b794890ee83b0e2b0578a6834fcc58ee672407813e96782d32f0c0e15daa735af42fed93084cbd63b47ef2927d60b3768a68e48f829d1c2b5b55fde2f3fabc717d4ed6aaa99ea1d572c0b4c32415bb2cbfe61c201fa00497c7105863f05e6790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb165d06b3fd5a99ecc583cfbf0cb1699fe4028107415e246a7669c16387d3289497be991fa3a499f2c77e2ca7797abe10bd9e2423fd38c8b94d3a816e5153b99b8552f5ef5ce3299b3792eb72cff04fa900940d566ce6a1f64cbd1c3d24f4db71d7b13a6a623194a784756003016daf18520a8773284c4ce1ade9087fed174e0e75eefc62aeda5c140d368b5ad6c9cb21231b76e80b4f0f9c6b4529d514faac54e6833c04819a3eca4b47cdad7c483c091bbcb663ba53c7818a37b15ae6eb0c0ed2a29b794890ee83b0e2b0578a6834fcc58ee672407813e96782d32f0c0e15daa735af42fed93084cbd63b47ef2927d60b3768a68e48f829d1c2b5b55fde2f3fabc717d4ed6aaa99ea1d572c0b4c32415bb2cbfe61c201fa00497c7105863f05e6790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1615d8281d5d0f52a8e88604a0c9d9c262fcd0784145e5eb879d7e9959ea784d1ccf807dc64858ab4484bd0cac986b1d4081bdc2156066c11fcd8f99ba5c69b9cc8a7f506382364bffd034b3ca8244405f0a8eced5e3e387eebcd71922a891e301e6cf78e665a873d917d4fd9e3098ff413ad9975c9da6d0f09327c9db9b9520f91c3e5571fdc6d1093ffab7114e878055cd87bcf027cbbef3cac0a7dd1852e5c49a8f429a0d745ba5128ce567111a384f754b6f24d07105acbec6e0dae52aca5faa108bb61dd71beecf2cd79484950f24abab066158145df3333708a8e90f555a483b8f8a462f61a6e0120c7a3c5b73d27747bce71f17b02feb60cc5ad9338d855ff46211118259c9b4f7807c501f6fbfe010afe0c54ae16877a8f7763ae52d2ac41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4163cbf7d18ec1cd9d877a5b48b7a4502fd099eec9718c469bb201a1d8058c776b5542d2137389f01f110e8c58cff49fa57da92d44d708e0d0a149903d279112243d67efb6f1b8679e6af34ee25af5c96c30410eb0266b24c7f258bc64b209733dfe6d5fa443ad3de110fcf822f002515826e1ac69e17ee4387a2ce15c33c1e84df23026299a6b3f30d946521ab8081bacc334e7f3dd983cf6296e9bcc07e4c93dd4f45783b4b90c8a38ef972e76f7c61ea7b0dfd368f0e121992d500aacbb27cc3731d2f5b6dfb4d6e15988eacfdcb1ed5611c8d1e095a15521b90b7e998236379b5a13e764b336d9b1637c4d2a76cbb30893983d47b447eb6f170b750927bcd444d5f2ded69604dabcdab7e10a2ace1facdfa6f2db7a0fe47fac5588be5c2abdf82dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416e48691d0e16b8458495017c588f150d5043019d9054fe66ed1e607632ebd957c3c4bcdf7e633a02256695aedff26f818da92d44d708e0d0a149903d279112243d67efb6f1b8679e6af34ee25af5c96c30410eb0266b24c7f258bc64b209733dfe6d5fa443ad3de110fcf822f002515826e1ac69e17ee4387a2ce15c33c1e84df23026299a6b3f30d946521ab8081bacc334e7f3dd983cf6296e9bcc07e4c93dd4f45783b4b90c8a38ef972e76f7c61ea7b0dfd368f0e121992d500aacbb27cc3731d2f5b6dfb4d6e15988eacfdcb1ed5611c8d1e095a15521b90b7e998236379b5a13e764b336d9b1637c4d2a76cbb30893983d47b447eb6f170b750927bcd444d5f2ded69604dabcdab7e10a2ace1facdfa6f2db7a0fe47fac5588be5c2abdf82dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c41641ff3bc58d459dc23cd8382f1eb82bdd32c214b6b33fdb67b763dfed8f5e0459d9c0d1b604be18383281a434c34c920a84c442aec9e5a625a1536a83fe0beba3accb117c202e6014d31f5cbba308bd29b0b58faeac29104a529d7a8a5b41f3b5be8174dc0ba7c4fb5bc5e2289721481a8b3feb0ceff21eb9717207a05f3f75ac97591e0d042fbc88ec23e9187362e2d96e33544214eeb0caa0f0b2a70bd71ac83d29e566b70f6386f0526e3c6dd28387a55b39487e56d20171242804dc461d9921f0cca7946bef75e5b6ab7d1103d745f310b1d86b78a3c5aada67283ce6ff37e6262c21f519736992826725de3997103a6dc14a9d23376839b9a538a69e4925e630dc1873a562b74bf30c40ec651d3d9661f933dceb64d4e5cec789680c3b9148e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4169994b87c656c9e12e437a35908e7f8557bf8c61148c63c7390b16c69fc35eb634fd1026ec9bbe04638c0ae7fbc04bfcb418bff64617abf148e678bcd999e91768496e23f8c78563275df2b629ff18c244d6e69e42ede07b69cdbf79d2ffd065456d8247f1c48090b12bb547e6fd8d447bb65afa3611fe9c7ec791ef1e2cbf8cd9ee13a13e4d0810adc334e33790c058f24dc0541f923576a4bf09dd102b8715dd931e11a9d9a3dd67b3512c71fba2cbc0fbdf9b869b5ce0fd5adc683629d203e39a2f780360716e21462aa4be8299961996a107faa1fc962d42eba17eb14bd31e13a0c0fc8eecc00aa000491319eeab28e9846d2673b2ac48fa48b37638e99b4142aa7674d60f5b81d415dc4013eca4471d89e3150fbadf81a8d31ca92d6b35848e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4167ab4d7ac4f7c7e8b129848f61e7978bd7105988f5501c58b745ae445abe278ee4fd1026ec9bbe04638c0ae7fbc04bfcb418bff64617abf148e678bcd999e91768496e23f8c78563275df2b629ff18c244d6e69e42ede07b69cdbf79d2ffd065456d8247f1c48090b12bb547e6fd8d447bb65afa3611fe9c7ec791ef1e2cbf8cd9ee13a13e4d0810adc334e33790c058f24dc0541f923576a4bf09dd102b8715dd931e11a9d9a3dd67b3512c71fba2cbc0fbdf9b869b5ce0fd5adc683629d203e39a2f780360716e21462aa4be8299961996a107faa1fc962d42eba17eb14bd31e13a0c0fc8eecc00aa000491319eeab28e9846d2673b2ac48fa48b37638e99b4142aa7674d60f5b81d415dc4013eca4471d89e3150fbadf81a8d31ca92d6b35848e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c41658a5a43bc5e08c62f2d8439d8b85970c50673ff45fd40af768115447dc069bb01881d74a7e56629429251f2910f247b82645f0e1ef1089680211ccae63c831daf23398f66bd4600639508a767bea2be95be8c80f520c60cc50f4129f426fd8e20d4479ae270ff5db972bde0c794018b11b3d2d2946bb20938194fe7349826e5687f05edd85e9de3f97f2f9f84be24ca6ae04e682fc194a49a40835020d1753e81fe9956375814d4e55f952b301e855b6fbf7398c55edb5cc3bf1ab502b17e88968aeda67f88087c82afa951df42ce8cc9d16a175da07e8b94f4c94f7d4b21dab6acebc324a259b3f32ad64464e6066dd292f3e470303d81b98b04b1ccb171406caf868703fc3995a140f834ebc1f461b71d89e3150fbadf81a8d31ca92d6b35848e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416b22467aa90965206165f56ff2346ecb100589232c271526c487458dbf123ab9d9179e36cb73940894ac04b158233d5ec9bc310010852b74531a3c48b67b06be998a7a18b13564478b564b848ea2e982135916cc60feb5cc90f6926b4e27cc6427126f8fd4eb3c9f150bb07225b704270289aa74a920f59c45bdeec7d3d6de15a0ba7dd58f8ac7e518bbf92c076d2aa69b2823c0b9fbddbb6ff937d11bc27485f1e068905b2183f7a564a3d68b7873ab8e01c78f13e88529f17dc82e54b7f5d37e75f08919d4e0fd43b2344a1fc1afadfc5926190b7aa4b62c95269e2d39f36255fd10b10c219e0965f1a8096cfb417f964c537107e3a6fb57b8822f15340c8d99c2fad3008f74a952048dcc78568b56ff8c20c934d9203105804277a0347bf09f579ac03cf23f7701913e6de90dd0b04a2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb164e5210b9f1e69545bb113806a2e5b34100589232c271526c487458dbf123ab9d9179e36cb73940894ac04b158233d5ec9bc310010852b74531a3c48b67b06be998a7a18b13564478b564b848ea2e982135916cc60feb5cc90f6926b4e27cc6427126f8fd4eb3c9f150bb07225b704270289aa74a920f59c45bdeec7d3d6de15a0ba7dd58f8ac7e518bbf92c076d2aa69b2823c0b9fbddbb6ff937d11bc27485f1e068905b2183f7a564a3d68b7873ab8e01c78f13e88529f17dc82e54b7f5d37e75f08919d4e0fd43b2344a1fc1afadfc5926190b7aa4b62c95269e2d39f36255fd10b10c219e0965f1a8096cfb417f964c537107e3a6fb57b8822f15340c8d99c2fad3008f74a952048dcc78568b56ff8c20c934d9203105804277a0347bf09f579ac03cf23f7701913e6de90dd0b04a2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16fbcb79a1efa45d4d7685d4a26110e34d1279e7f4cb9b6576d601752792118fcb72a1c06450852fd9b93d7ffefc8416d56bc889eacbdde64bcc4892172f4f3ecf06c96b946b0a49c525f0c6cce629727882252e39010fb6083f6cf0b543194f1e44ace27a8188f65b784e14c10570d480b3cc6e12e0c124781da0cf07baed1ee205b15f929d42e1e77508363d6b4dfe74e944d3f13c8c53e64c35fab192b524df0c3f703a81d52d51c830d959e7f4ed0727b5e88b5e1c933eae231866db8cc2d7a6fcf8ea51497dc3b777adee625956bad3890a0039a73f51bf195c8d8465046536bc0a3af5dc35b5e564aa501a57abb01a1bacc7577de052fe98fd20fb6bdcd2db14bd4ef83015f905b5d063aa8537f35721d802c3f45313de668784f2de619fc41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4166abde17b96e4dfe6c38b1aa615dc3a96323a14cb039596aacfbd637c27f5785d9c2816203094bb041c9dcc301329245fe109903378c42a8e1fa26cffe129803b30c2164b57c26a7dacff398e693e52982e1c327b07254513079d2988970831ad785475a056ce900b77da98fbde2779ff63bb8ff3c21aefb172e2541954847038a3e3d27e13bea7bd65e7ac882d87586251d3bf159ad2ab394548b361cb3ad3828830efa850f923b46eea1f7c61a7365741a78288d872769e1f5dece7dcd9451e526081b8bcc82485462b9aeba9b59635123c3f430f7c13323d00e68351c46887e808c06378fe09cde46ca0dee24e48292b2adae78a8f10cc98345bcd983b4bf194051bf92890679bfca9f95f755da219d2594ebe365c512eeead8cc0be9a8dc813ead248e7378b18d616bfdf40f71750bfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416a7da56927cd24bcc9fae62e1704deb10af8a1734ab7ccc8c589e77486ec6db28613d81c2f50a6e4a8d3b64210191d91d7b7de6c44de11cb91e888540271a12da7954fc3af5babe135028428437d227e91aaa7436d86551ae5252eb3764e9f71fc8a942a0e0189a42ea581f8304391f9635706afe99b35fbe63cc06c52329eaa5ee62b37c9489a9bf6692d647688c8f9df1b5c907d34edb9b61f1dbecda334fac8830efa850f923b46eea1f7c61a7365741a78288d872769e1f5dece7dcd9451e526081b8bcc82485462b9aeba9b59635123c3f430f7c13323d00e68351c46887e808c06378fe09cde46ca0dee24e48292b2adae78a8f10cc98345bcd983b4bf194051bf92890679bfca9f95f755da219d2594ebe365c512eeead8cc0be9a8dc813ead248e7378b18d616bfdf40f71750bfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416286829e372c29ffbe74e078e784bd5d4fcfa02b3791f741c7d5dd95f794c39a9ec67094f783474fcc18927d45ec8dfb6a5a8a60047211c28d2bf3f2ef6f19839b416af0bb36a5b721d3266dc2c3aa17b6d034304788afd8e44f28268a6fd54a2fa9e42d0f97ed7c7db1debe4e57babec49d7433f77e458c486e059bf207d08778f48bd08321b3efb6213094efc979ff86adfd09da06a85db64243c2723c686241251df7ad5bd680a2e72a2aceb57ce6a037ae39c31a6ee36a4f81798e8c213d27dddbf719463f8f87c45e43f2b4c92c7e424f1ee4a24bd5b875271e06943225cde747764a7f823e6d3ff1f19e23349cdd8f8be083bb52597924f4e865132efb02d60f2308bee7f72569de9f27154a1d31b41314b2ab014109577cc7beecedfdcfd8cb56636f2bdc7d2a4f11d592e78809e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16cfd8b42733c9c672dcaa41c5c69b6ea297de86da3258cf9a87bbb307387bce1a4b6600aacf6bb65bd9e07f16ad3d9c1d87c465ddd2fecf6732b9f0c9fa523a6f73b69d4fb81cf1bca4339bff02142c339672db36e5950550d62091b0759b126abce87adfeb56905aa5020070b55921d2e3eaef7ac27aa15630f603c874cd6c1f20bae89a8e9af9d933aff5f61d8505297de50f53ba2b74ce8ae51808e07153db9ae14cbc9707aac713d46d09399e152b772f7a1afabebe668afe75358d10e4a9d7ee499432c95f4c2240427b5c151a39ae639d3b56d80cdd6e4bf019f1be340508efdab122119008fd72ef6c06bdd7a63e5848e5376358743f9bcb67e6388567699cf4ea510abcc46fc77174faa5d8871a49905f263bc949fafe3403ce26feab16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16f33b48a5de8f6d412b1fca71fec1427fa0a955cbca0fb391ff8a6caa41f3f2f762cf60ba30d0ce58199c0e263679d7b2dda7df311db08c8c792eca3e70c4b12673b69d4fb81cf1bca4339bff02142c339672db36e5950550d62091b0759b126abce87adfeb56905aa5020070b55921d2e3eaef7ac27aa15630f603c874cd6c1f20bae89a8e9af9d933aff5f61d8505297de50f53ba2b74ce8ae51808e07153db9ae14cbc9707aac713d46d09399e152b772f7a1afabebe668afe75358d10e4a9d7ee499432c95f4c2240427b5c151a39ae639d3b56d80cdd6e4bf019f1be340508efdab122119008fd72ef6c06bdd7a63e5848e5376358743f9bcb67e6388567699cf4ea510abcc46fc77174faa5d8871a49905f263bc949fafe3403ce26feab16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16c500830a767e50ff4b9e7e6917d510cc2951e16d57e059724ae6962ea6a99aed85ea879815b22a828fc8317eb2cda692893f7886dfdaf8e7f919a99c1c9906a1899a7847af629962d0c69eb36ee6b0d9236077c4751587dbd630f2731ba8f8b9b97cf9f353e5101032dceba0949fd2ce4f77e9669cbc0d75302cf10b2b761ae1493f153a0068a7f059f247ccbc51b8823eb5a9c458b4fe07b79d8c11dad1901346690edf8acdeb402c317a1eccb5198edf6a5dadd08d58c80894365a45419147c3f5e1ad7934b3a287991752eebf22d12c9c62602fb437f054e558f39b47a9f5262298a86a285096c0ea0b1114bb3629ccabbd81723132c07d574266aaae21b8699cf4ea510abcc46fc77174faa5d8871a49905f263bc949fafe3403ce26feab16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1678066814a17ed567863b97b9e4e377da61e91668b8c60b36d61957deb8b1b68304b03e73f3b1be2707efc3f8177e7f87821fce74ae6527d29d7e6143c7092a2aec766cba3c9ce8f2dcefc1e8d0d3e15ea691821ec80de5f492ace1b029e20950c3475c0143212854426a8550c5f59ea037cb652e7b82ba6c1aa7cb5e8e289874275ac0434a4344989eb1aaae1b47e0780101b3df6fd6c627c0c8cd567c4e4480115d8e1e19635dd343ec699e6e9b3c48c887b828eb71f38af48f8d2d7bd45c0951aff0b7587aebd1e6eb0bbe4d711004d9c67e3cec00c8097f9ec6edfb1f6583fc92ac1458177a561f82bd03b6fd1c516970f66843e3f9724837eea1c95d7b6a80d14e5c1c4226db60566c270e1794c5db937ffb1542081ceac27f5a7775640c16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb166f72f43645792cca8ad6160f202ef38561e91668b8c60b36d61957deb8b1b68304b03e73f3b1be2707efc3f8177e7f87821fce74ae6527d29d7e6143c7092a2aec766cba3c9ce8f2dcefc1e8d0d3e15ea691821ec80de5f492ace1b029e20950c3475c0143212854426a8550c5f59ea037cb652e7b82ba6c1aa7cb5e8e289874275ac0434a4344989eb1aaae1b47e0780101b3df6fd6c627c0c8cd567c4e4480115d8e1e19635dd343ec699e6e9b3c48c887b828eb71f38af48f8d2d7bd45c0951aff0b7587aebd1e6eb0bbe4d711004d9c67e3cec00c8097f9ec6edfb1f6583fc92ac1458177a561f82bd03b6fd1c516970f66843e3f9724837eea1c95d7b6a80d14e5c1c4226db60566c270e1794c5db937ffb1542081ceac27f5a7775640c16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1696b21be22c6625679d46a58e8e6b1744d003409e26d1d7df8ebe426b24097db463f287c36ac118e0c35e1c419cd58e9ba3efb436713f706d6545db89556c2cc246b8a0b0b6d60c3a495489a31222096168be3ed56d34d5e0826f5c4eaad5eba16827c849926fd4a4bf7e822631b582cefbef4f0398d6256e3f0c7a4fcf733e5577fcced272836a2333ab29c53f2ff838a6aa7116c1efe4005d8c3be9a17ae5957e4e6ca2be0092ad2833a68109bf28dac863db0d2aec4c28fe19f69dce520bf751aff0b7587aebd1e6eb0bbe4d711004d9c67e3cec00c8097f9ec6edfb1f6583fc92ac1458177a561f82bd03b6fd1c516970f66843e3f9724837eea1c95d7b6a80d14e5c1c4226db60566c270e1794c5db937ffb1542081ceac27f5a7775640c16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16facc8817e483dcf54e539c344b2f460b51cc989ebd15059b2db06f109d2ab465a8936979a8e13b13a11b384585eebdcc5c0e90c58230e9dc773bd76653f1772c79b686053e71cbe85ba029b66212935405d3ac5b89cfe7ccad23d93e7795b62dac5fb12c5f7f240d94553a763fdcd06961280fb8789e273b4d5ac9ce07a7a7e5a2027f797642a1b1d0146a73c2756710d58a186dce659de5ec14fdfe18b1c15252b15e96c9c4e9a9e6e7f34cce8ba0b42b6b2f79657388ee9ddb649835ae32e73a5ccdf81c66dc5cee71f599cccbbc8941fbb252d1e7d7e4e6fa5c1b82ecd6e3e659d19a95eb306348fdde15e5ca4cc74b29937e36068741c522eb0ce29eb0763ff379a1d344409d5abb6510773269c61a49905f263bc949fafe3403ce26feab16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16e577484069f02750bc6f116ad0acecb33ab2ad493d26928dbbb9b6367c81df2351a716e3135fe56deafcae583f335a261baf4abf16e4d32b15bec0520d65f51438d384ecc8ae380d5c6eeb90f1bdc92d05d3ac5b89cfe7ccad23d93e7795b62dac5fb12c5f7f240d94553a763fdcd06961280fb8789e273b4d5ac9ce07a7a7e5a2027f797642a1b1d0146a73c2756710d58a186dce659de5ec14fdfe18b1c15252b15e96c9c4e9a9e6e7f34cce8ba0b42b6b2f79657388ee9ddb649835ae32e73a5ccdf81c66dc5cee71f599cccbbc8941fbb252d1e7d7e4e6fa5c1b82ecd6e3e659d19a95eb306348fdde15e5ca4cc74b29937e36068741c522eb0ce29eb0763ff379a1d344409d5abb6510773269c61a49905f263bc949fafe3403ce26feab16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16e8c379fbdc245aa36646857ecb12eb7fba457f790fb1c4d6cbdd17805456796e3f1e4ee9168e55ef2172685c4d31c23719ce5b9426f48daf4fff25a76a32f8ca6b5227100e3e3f14932fecfa64056574360a8fcd157e8ce14cedbcbf6c008e00680961cd145f2222566400438b520f0e05e2205e7001c8202e8a180a89752d5899323d702ed9d8a23ce2c7cd76ba031f4f625297ac7b6f4745d5df27ef8490989c1e7b758cd5db0e8f621dab3ad6198ab12a5b343767105da97796bbcffe4a7371a47068686b4fc8494b7634ebb5d8ac457800caa7759d6ce7a6f3cb41cb0ef757f5917f09402d58d23334606ad080710c2510d7fe3a88ee8cf0c9f9ecc87ac73ff379a1d344409d5abb6510773269c61a49905f263bc949fafe3403ce26feab16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16cd44f1f0f06d7988f04b2fff9d0d30be1a459f6c6e6e210d246bdb287b1a32d0d4b86c872d27fdfc39581054e1577c09ec81b6335f69eca12455cf93f8ca6e35a6c7fb5bd38127eb5cd3d220c3d23dc22299e4bcf5048a665858ab64062c780d8843956918ef39bf6188ef6c33b1818e049601a3eefc1a127dacf7d3c62ba98ae4bc8b038528e06a50b556e987598e0acf16e48fa8628b5d3bd1ef4184d489298768e431433adca049d944154600505a1300a01acd6d580af2eecdd9482f36f2f117d00b31bc062ce165db6fc1b8607d3f94b9d4dc6c8ac3f714394bca9dca55d9c07ce487f3b0388bdf0250c5d60c7c9261a1402e1f5f5f66d35058b534440dd27d38cb20e9a2c225fe5ec9c0cb1d581ea2d22103d999ae9e2981bfabf39b4777de1f496eeb4525f602187cdb005a7e89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16a916ff577bdffb6da9a33af125abafb31a459f6c6e6e210d246bdb287b1a32d0d4b86c872d27fdfc39581054e1577c09ec81b6335f69eca12455cf93f8ca6e35a6c7fb5bd38127eb5cd3d220c3d23dc22299e4bcf5048a665858ab64062c780d8843956918ef39bf6188ef6c33b1818e049601a3eefc1a127dacf7d3c62ba98ae4bc8b038528e06a50b556e987598e0acf16e48fa8628b5d3bd1ef4184d489298768e431433adca049d944154600505a1300a01acd6d580af2eecdd9482f36f2f117d00b31bc062ce165db6fc1b8607d3f94b9d4dc6c8ac3f714394bca9dca55d9c07ce487f3b0388bdf0250c5d60c7c9261a1402e1f5f5f66d35058b534440dd27d38cb20e9a2c225fe5ec9c0cb1d581ea2d22103d999ae9e2981bfabf39b4777de1f496eeb4525f602187cdb005a7e89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16bdf589eea4a2fc224e43355a8efdeaa8997437d432bc6249b0f27c2dbdce55bda871639dcf1c8c2dd800d86c5f87da34a6457e04cc1d2b179cef893c219e9b3c435ce66e464cf4da408b1192c2938a97b1ec28350ac24e9cef116314f8ec944543b097c9cd0e2f0d208787f29f94e770b9d1f5582ee1244e7ac6225414efc51fb22c50052c89452df0a467eabcf765bc7d2770b73b490e349c8f30c0b3c3f951f6072c05d890b3b95d1803a1be83d0d9509562589f1262a8a2f88d38e5159c26368bcab7be1b6645766e5070ad82da4533bb8bfc780cc20d40231b11a72bb50533b1701d29a5503db790d75097ea51553a6dc14a9d23376839b9a538a69e4925e630dc1873a562b74bf30c40ec651d3d9661f933dceb64d4e5cec789680c3b9148e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c41657dc13f204f65e38eff4617d988ca253cf55c878e845e4dc109876a32efe699f182a4c4b50f4957758cee8f30c7d62918a747463d05e48001bf82786eb54b91f82550e988fe2c208aa356185aab9fb7364139fd6358632f6dcf2803747e16753758abdff326fcaf90ad1a3a28274fe797d21ff2eb91267eaf96f42cd3f634142d1694e018b80ae15ca1f1ddafcd191d4a40dec4486fe0702967c7c85e1a38404f934597eda3f713a47912a99d7ea1b9f5b1662823a0bc806508178fefcbc94aa4e15100acbfea9ead621949c1e100018f058bae922eccf7f5056d99263c64dfeb8351384a3aa8f84461e302af72f3192b31ac96d549d35de84002b343efa8933483fae086cb988ae35ccdba7cbda39a537b98c2fb1c2d63993afa1f2be32d04ddd4b321eb4d8f9c7a5a96092c8d69e7fbfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416e62a5bae76c6fc6ed8ef5c956d39e6b8f8b97008da5d18acbed55f3341578a0d94f64af00ef73164522b9babc82fcc378a747463d05e48001bf82786eb54b91f82550e988fe2c208aa356185aab9fb7364139fd6358632f6dcf2803747e16753758abdff326fcaf90ad1a3a28274fe797d21ff2eb91267eaf96f42cd3f634142d1694e018b80ae15ca1f1ddafcd191d4a40dec4486fe0702967c7c85e1a38404f934597eda3f713a47912a99d7ea1b9f5b1662823a0bc806508178fefcbc94aa4e15100acbfea9ead621949c1e100018f058bae922eccf7f5056d99263c64dfeb8351384a3aa8f84461e302af72f3192b31ac96d549d35de84002b343efa8933483fae086cb988ae35ccdba7cbda39a537b98c2fb1c2d63993afa1f2be32d04ddd4b321eb4d8f9c7a5a96092c8d69e7fbfa97c3261b2765822097fd4263f16fd40cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4166a6ed829894e49927a2a975d4a3bc135d61bf05e93762bc769d4c383b0aaac1d8d7a010931102f84bf1fbbb24502eee44b75773a7bd83f2164f825fe58f8d5c594c37deb48c7212da2bb7c2a36d5042b4c3c5d3b12c65a2d1e85df4b66243755fdc5bdf9a37db06066283fd47677c61ab87d6832525bd3d334d83ebee8ed75461f16c924c4d7405065fdd5f65fcd963ba9183fed0a99afa32e0af40342588d97bbe13b6ddca1946c312ed3308d4d6495ada17754b823669d1467f1bd7a8a0897c6fdef3c9a242558bd2785b92ea0cd1cdb13db5746f8830a88ea1fcd6c7b3f2d65ef2280f5cd23040d0963020fc7c35801af1596232154bcc064feb9ae3a347567f32631c4a645972d8f7fa19bea1cd7535b272c101f94871be988774568dbdae6e1711f5af0f03c6d2054ec4c2c820ed8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16cf783fcc35f43da121e13283eb645a656cc99a160a271edb4becebe13da4be0d7e3c0337d7b51e31646d1d07893c2231f58316a8cdb0912fe3613aeca1558e8495d86faceaf31fe5068aaa74a86bfb3a7cecd0830807d26a090b024b4f02666a34a714cad25b6e4adb621b74c0dd8d46c3e58677dbb12a4975b6d28aa0b2c3d9da5b7ccc8baf0cccdf019c473b95f32101926d5a1da9af509b40448c87b16994041167a68f3aa98173c554853690bc6b69d187bf97960014698ea1b30a274e9d456a7982a07f2c75dbf14c66101860535589699267396bcd72e0daf4d0d909b031e3580b02dc2d066f7c7e9d9fd4ea05d5c64599225505c220b14456e004bf5a946799885e9b39e3b9b3c211d0d8533acdfa6f2db7a0fe47fac5588be5c2abdf82dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416910ef3c958dc3fcf840e6473f25d398122cb2b67d34dc5602905fdf88a5ecf657e3c0337d7b51e31646d1d07893c2231f58316a8cdb0912fe3613aeca1558e8495d86faceaf31fe5068aaa74a86bfb3a7cecd0830807d26a090b024b4f02666a34a714cad25b6e4adb621b74c0dd8d46c3e58677dbb12a4975b6d28aa0b2c3d9da5b7ccc8baf0cccdf019c473b95f32101926d5a1da9af509b40448c87b16994041167a68f3aa98173c554853690bc6b69d187bf97960014698ea1b30a274e9d456a7982a07f2c75dbf14c66101860535589699267396bcd72e0daf4d0d909b031e3580b02dc2d066f7c7e9d9fd4ea05d5c64599225505c220b14456e004bf5a946799885e9b39e3b9b3c211d0d8533acdfa6f2db7a0fe47fac5588be5c2abdf82dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416efafa6aaac62225685f1b72d01ccf799a8af5ef77e83cb8b71c79d0561da83f5277f2bc1c0a9051d3138e71ac468cddb8c5db4d545bb9fdff3876f7134620072007af7d7970aa335d72dc3435e35ac7ae7c11dc000332def2c6f8e2c8d3c0257161ac1cd462e5c594256c19bd1f3a7743db7e859c08af49c6d65e0fea7146bdc8df0ee34db1e205eec60da4ad798b19f7ad6d041ae80b5d30fb5a8b638923fe4715a4b9fd925acb9e7d5712464a9f4c4b09b2fe317863787b537aac515c27c5aade74624c82f28634c55f9b3fbb2622d330417b3b7a0ea3a6966642301fe38fff9cf7fafea0aad0b587cbc66a997534055acfc3e896c1687f416179ce8189901a28892672bc9b229f3555a4c6a9b5426cf852dc3c78659d2a0472f496bf1b0986f94f251d28d300d2e3065f07ba61d1d89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16571ab9c95611bef0b6a4755b6f2c3a97e110af5de4ce89a98ae952a0536b4e8bb773cd7a8886ed5fb6d725cc530d693b5789ae8ee77f916a05a8c2de735b80ee78e6294cff0a1b10083a327c287133f1fbdb82c67206d97180af7133403e107043d2f8cb83737dab1969f69afe78cb60e8fccb51150cb2e59a854184dbd3862cc0fb640a141bebbd8c47e80eba3b41eb7cc5603cd7a8d76bee50a128ec22424260ed99b6de438d0969430bdbefae9648c1653840233473a2d0fcb496b6de225d966ebc2a0e2230ab5ad8bced72a03ad0bdaeb9a67a72f3d90babe7cd05f89c41e68bc6616ba03cab70025814ff422fd4413e48c66c7968b86e7b4d9f87a1d7379c2fad3008f74a952048dcc78568b56ff8c20c934d9203105804277a0347bf09f579ac03cf23f7701913e6de90dd0b04a2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16b2fd0bb9dca2b8b2d1a7639fdbb3a90fe110af5de4ce89a98ae952a0536b4e8bb773cd7a8886ed5fb6d725cc530d693b5789ae8ee77f916a05a8c2de735b80ee78e6294cff0a1b10083a327c287133f1fbdb82c67206d97180af7133403e107043d2f8cb83737dab1969f69afe78cb60e8fccb51150cb2e59a854184dbd3862cc0fb640a141bebbd8c47e80eba3b41eb7cc5603cd7a8d76bee50a128ec22424260ed99b6de438d0969430bdbefae9648c1653840233473a2d0fcb496b6de225d966ebc2a0e2230ab5ad8bced72a03ad0bdaeb9a67a72f3d90babe7cd05f89c41e68bc6616ba03cab70025814ff422fd4413e48c66c7968b86e7b4d9f87a1d7379c2fad3008f74a952048dcc78568b56ff8c20c934d9203105804277a0347bf09f579ac03cf23f7701913e6de90dd0b04a2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16562a8d1e1f29132b38f0adab6ab94913d571c247c43694254e9197937cc6a544aaf25578e707b7a15faa2993dcbd9f0a6b2ce5ac837fb4bbaa77bf0691fd803dd46f3e5d945bcdf807c127f21e4f7228efbd2ff7594f224438e76065914422d9c9191b0da98a32ebedaf175dbf61e30bce1d8ccde0d307b60ede252cf2425a1c6cecee9b39ffdd55c7a94e22ece8516ba201262ba349a08ed1e5a95aecae17adf9e4dc18f8fac4b98cf1ad28333359780c61a0d83ee7926d54d8e96aab1f7cf38e8d5cedcf1f3f5b9ff545060dfebe7b76d40aaae81a7e67de8cf4a31fa533689c35f2599ecccdf984aa4fe44952a54a4b29937e36068741c522eb0ce29eb0763ff379a1d344409d5abb6510773269c61a49905f263bc949fafe3403ce26feab16bc420d024e52b408934ae9e048879e9e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16ba3035aaf883e3f70ee5799045497361e6c2998cb762aed9e3b80030c2ed015a688895867e8733b30c469c4a68a4fd2bc36a21ec2f1f377e0feb0d42d04a89ecbb4d043cbfbc190f74bb79f512f2a6a6f9f2b319f9010a2c3a6025f71fdb47877fcc546a47267d53eb3a45c8340450be4b0e2b68abb5265d28604d21caa91c843b2bb436d21d503fc0387f71d4a83744675fb944d18ab05155e452eba1838f50ba27d62546430f6f6dd5009ffe072fa26d272a79e7b0faad7fec81b5196d223c8ea5bc3eb5d1c4d6c67f5c89f81420fd8ee672407813e96782d32f0c0e15daa735af42fed93084cbd63b47ef2927d60b3768a68e48f829d1c2b5b55fde2f3fabc717d4ed6aaa99ea1d572c0b4c32415bb2cbfe61c201fa00497c7105863f05e6790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb160e16fee8e9a4e49a1819c84db2583d52bab50e3d03e05cac5f1f7362f017f5951db1477928dbc8411c8f5a46e640c8324c1568c776ad63d9fdf13a698efaab4fbb4d043cbfbc190f74bb79f512f2a6a6f9f2b319f9010a2c3a6025f71fdb47877fcc546a47267d53eb3a45c8340450be4b0e2b68abb5265d28604d21caa91c843b2bb436d21d503fc0387f71d4a83744675fb944d18ab05155e452eba1838f50ba27d62546430f6f6dd5009ffe072fa26d272a79e7b0faad7fec81b5196d223c8ea5bc3eb5d1c4d6c67f5c89f81420fd8ee672407813e96782d32f0c0e15daa735af42fed93084cbd63b47ef2927d60b3768a68e48f829d1c2b5b55fde2f3fabc717d4ed6aaa99ea1d572c0b4c32415bb2cbfe61c201fa00497c7105863f05e6790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1623bd15376b63461de32e9f79fa1d5abf051f9e16419384b5ad746a1af6bb9efe59a35c48f4fe3dd1c17ee0c2155a3a8c849e2148894039b613707fbdfc376240b009b86148391f1726b6721a0030cc4ef4b4139773154fa5786cf920ea83eb761b444640ed41c54763a2035ef902046a7c13cdc6bed299fa8030805893fdb9e4552dd435d8d09ff2739db0cc990360d0d67bcc953af7f5bc07a866131fdc2d84b233105a8e0bd0cd8304bcad2a83b7aafee3eed586fe85f9c069d5d98e5fc65b9772a553815f0f0bd939b6a26d6855d5ce649a7753813a1710bd90343238e56d970f278b01f1be971821cb5693d1f4221787e7af2cbf087e4d9ebbbedd2bbbaf25e4517de334d5668d242bc9864a20ceb8d4dd7835d62f358ee6afcd81cd9ae082dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416af4915d1d320ec3965849b86f25654d39c5225559efcadc4d4b5e9fafe4aa1890d4263db6493eaf7c5ffea5d331023452d16bca0240bcc970f28c0bdcb50302d647e4bdbe187d2a269d476ee9513d7674fe7e52f41095e5eb35317ede096de77162923106ac79c45ae4532bf20efdf8745d334aa3b9483b187bce45754b2d5d1d319bea791beac29d3270fa252eb1df3ba10317adc85bd9720a800d0b44c6790be3a40a24941c1cad820d05607170cdf800b1b7e021eeb0a3042d1d939b76f22c3cad1f4ef9671fe5c8a6fe37c4c2a0fe4f49d898006522d174befd3bdd98115362a7f55576db67cc3ab23efbaa7a56e1622f5444a4ea02d66e65ce110c26cbd946799885e9b39e3b9b3c211d0d8533acdfa6f2db7a0fe47fac5588be5c2abdf82dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4168c71c893cfca7323c1f3379f4710cca99c5225559efcadc4d4b5e9fafe4aa1890d4263db6493eaf7c5ffea5d331023452d16bca0240bcc970f28c0bdcb50302d647e4bdbe187d2a269d476ee9513d7674fe7e52f41095e5eb35317ede096de77162923106ac79c45ae4532bf20efdf8745d334aa3b9483b187bce45754b2d5d1d319bea791beac29d3270fa252eb1df3ba10317adc85bd9720a800d0b44c6790be3a40a24941c1cad820d05607170cdf800b1b7e021eeb0a3042d1d939b76f22c3cad1f4ef9671fe5c8a6fe37c4c2a0fe4f49d898006522d174befd3bdd98115362a7f55576db67cc3ab23efbaa7a56e1622f5444a4ea02d66e65ce110c26cbd946799885e9b39e3b9b3c211d0d8533acdfa6f2db7a0fe47fac5588be5c2abdf82dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416b6398875654131978ff2efe1d9e35e20694069203180d55216f0aeea07de92d3b978e55c7be6722d5ec651bc490b8808316df36b3a23fe4f3bf7adf871aa6b2a1a6d75249f8f4ceadabe4ee3a6e8715f5067d9e54d33fd3af812d68fb02c3e8a8da43d4ba5f0c05319d9dd7d14020c069d00f1e08decc027b2743f8bce0efb98d109f641695ee24e8fa4464e59dbd01bc34a2cdf2fcf4a7d8b34d73753e889d2c93db5732e909de43b864eff183fa166950c4807587d2fc115a150bd8d8516e176d2aaf741c1444265a160fd7809fe2c6041dd5e4075a733741291d46ff6ad6bc36ff59d674553592f7030dea0f78f6ee2c984d95da372b5af234b9721c9e3b3dd012deaa0b05494eb64f4a4feae4dbccf852dc3c78659d2a0472f496bf1b0986f94f251d28d300d2e3065f07ba61d1d89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16b5fb4408fb1a2c6030ad1e3835c488ca5f8b6c5163bad7dffde8a25f7cce03bcf798571f833ee0bced35a53c006dde3011d59a25fd526707b4e8d398c756cd2996f5823a5c878613593590f6be010140de87575bd5fc08edaab51d69000394fb9d243878ef0ae7bbd20ceb74b08f584e5d95c4a5eb4dc94f447f4790d80c0c63315bd54a5924ec7e5a2cc408837eb0b45b71d4781be92045bc961711f037191842d61c7ae3cf624eaed4348b0568d293de2a222d0d545b4d956955a626cb6c0a3a9da26c7195f696f4f4fbb31cbd2a542365815c960c2d23acc21242ca4f296652d82d90bbbdcc7509e7bc8d27d58c52d8a411b53bf057988c332022f8d8413ea3a3871c6b1b49b640482e97e5ec6d61432d11633ced1a5523ecd3dd5ecfe73713a152183991c8448792b4b2616b663267c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416b2a20d5cb7cf5059b7e1f5ebc0b08deb5f8b6c5163bad7dffde8a25f7cce03bcf798571f833ee0bced35a53c006dde3011d59a25fd526707b4e8d398c756cd2996f5823a5c878613593590f6be010140de87575bd5fc08edaab51d69000394fb9d243878ef0ae7bbd20ceb74b08f584e5d95c4a5eb4dc94f447f4790d80c0c63315bd54a5924ec7e5a2cc408837eb0b45b71d4781be92045bc961711f037191842d61c7ae3cf624eaed4348b0568d293de2a222d0d545b4d956955a626cb6c0a3a9da26c7195f696f4f4fbb31cbd2a542365815c960c2d23acc21242ca4f296652d82d90bbbdcc7509e7bc8d27d58c52d8a411b53bf057988c332022f8d8413ea3a3871c6b1b49b640482e97e5ec6d61432d11633ced1a5523ecd3dd5ecfe73713a152183991c8448792b4b2616b663267c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416256e49f05855797b1359afd0709c6387bb1e078010816e95bd11cd270cbef7c96a9ea3ee1787b0e73bb2852934b2167702700df9880fc8b4bdb70413273e91522ef740ef3d09d91d62500dbea12d5295092afe409ea690843dbd6b19fc54c6404f52276e08c93930f3f5af370fda1b880dfc4f72e3b7f54e6b4c9a72e304702f8049195bfe197ab0d4c81baa5f757395a13384f8a334d485992c070049212f0b8e035924afa083042580f145de60ace34b961a4961a518e3cf40e64a2c1533f6ef55ec8fa5424cb39482be667d609ffb4688957ebd7a83d792d14b2b01e14dcab9998bfc946013aba937de843d3f49ef1787e7af2cbf087e4d9ebbbedd2bbbaf25e4517de334d5668d242bc9864a20ceb8d4dd7835d62f358ee6afcd81cd9ae082dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416d283eb6d07c421c72ef7845ce6a91bd04dbf17609d22d0c1b3be85a2a171691206ecd4331f07ea902e6af018665fd675c3d075b515ff92ffe5a3914a3e6be848b800cd143cbe697e04b8e6317f586110bc693c0fa0b8e8b65c1e11d68718493ee4b705a3f76f3a1b2f93dbf737c2d126747109624d9017ca0c52fe275080377e327c6f25723f84af903ac9f457dfc0329481bee3ea35756bf03985bdd7e23f85a1167cd28ad404dec4dd11529aa40c78908376af8fcaa55276bafb761bc311f602e4e3b579b34cf115ecb8175f3f2002d1b7d4a91387c3c1ddf3a200e255ea1bb08adb6145e26f7a7a40901f8c974b314166b1612e48e1ce6de5be2aa66ad4b7841feebe05cea6b73260b87c9c259598432d11633ced1a5523ecd3dd5ecfe73713a152183991c8448792b4b2616b663267c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4160eab884b6f435abe08249d0187d2d13830ec10160ca268c239700fa20e68d3ed2e66387746d65589795c43df9bb8adf96bdcf7fa2f10c0982655965020bde5eb9657ac14f1529378cef8ac4161a65e1c3874971f5aae9e0f8df711db05381340fb6aac9c5e1a2ffe19d8737fcadd8f4c747109624d9017ca0c52fe275080377e327c6f25723f84af903ac9f457dfc0329481bee3ea35756bf03985bdd7e23f85a1167cd28ad404dec4dd11529aa40c78908376af8fcaa55276bafb761bc311f602e4e3b579b34cf115ecb8175f3f2002d1b7d4a91387c3c1ddf3a200e255ea1bb08adb6145e26f7a7a40901f8c974b314166b1612e48e1ce6de5be2aa66ad4b7841feebe05cea6b73260b87c9c259598432d11633ced1a5523ecd3dd5ecfe73713a152183991c8448792b4b2616b663267c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c41669881afdcdf581576815a71293c5f60c6c47043432a9c9da80e7bc09dd56f173e90099d2ccaf87ebe34ad3a67767f18930fe6b7117f624163eea033c18c865274abcb5af447f910fbf85b03542ee3098b59c10834bd6f0dda7321bcbe79369efd83108ec42209471058dcc417fe995163b112bcc77a3fd0a7c887d02a55a268b0294c7f62e391848dd9ba348f1c934bf9eb33915ec4fe20daf8e21b81f70fc68b57179d5217b14ef51e2a2a6bd5a3bcc49b6c9e1fb7e88d5eccca55d7c5480040909bbde15733e3570c43f2e8908e7513e9fa0e052ed2654b0a40aae43b846f7362a7f55576db67cc3ab23efbaa7a56e1622f5444a4ea02d66e65ce110c26cbd946799885e9b39e3b9b3c211d0d8533acdfa6f2db7a0fe47fac5588be5c2abdf82dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4162bd915da069d3030c894575e2ff462fabaabb686497dcb338bc7bb6957f9efd59d6a38aaf9841241113f79b67b128372a63f160d3716e8b6c915fb1bdcef126e003a1d7b1502be5b7f4508b799b58246a3b66124bdccbfceaff7d54830cd06a3f1cd95fb2729aec037889ae1cb33606b1b12d88b81adcd0b38841c6427d9e8bc6d8303569ce99a268f9251a15830ba7f96c50981cda7df7888b4cd27b755f70acfd6a9969ad7a548c87fde45910a858b173445d7f51db15dff5fe3bf00f4a6eb73307d96c5330b4a41258652cf1f89274e9c06186a975e2d97f250311074d56679cccce49078322275d66651054aab8c873fe0dd8800983e9020688d545b8bb016a9c310ebee461ca03c083aba13bd6ac8043808a2cb1d5a04c94edc5d748bcf77de1f496eeb4525f602187cdb005a7e89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb1642d4a6783334fa4fc73db87cd8181dd8fbe8a37822c61c4c03bc0382546ad8d7cc64bd93025ef8ea1ae5c8e0ae118c6ea63f160d3716e8b6c915fb1bdcef126e003a1d7b1502be5b7f4508b799b58246a3b66124bdccbfceaff7d54830cd06a3f1cd95fb2729aec037889ae1cb33606b1b12d88b81adcd0b38841c6427d9e8bc6d8303569ce99a268f9251a15830ba7f96c50981cda7df7888b4cd27b755f70acfd6a9969ad7a548c87fde45910a858b173445d7f51db15dff5fe3bf00f4a6eb73307d96c5330b4a41258652cf1f89274e9c06186a975e2d97f250311074d56679cccce49078322275d66651054aab8c873fe0dd8800983e9020688d545b8bb016a9c310ebee461ca03c083aba13bd6ac8043808a2cb1d5a04c94edc5d748bcf77de1f496eeb4525f602187cdb005a7e89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16c0ccf106a9e9cbb6e01492976bf765ebaf189e4fb7448762b7d96b1c503aa6441b2e38775387f0cd160441612954c5bb6d352be71a605f894e1151a5d851606e206489ba17de660b893a8bc18b63c982a1e259f7343a4cddfd22aec78b4f55ce2020ace5beb55a60d5502b784f9324476e8597beeaedeef6a6f01fd7c0308cbebc5c034e685c47aad35a7de918eee0de87b73995aa955988d0b42cccd517b9a56042267570a1325ac8c30e8a744ccdd7725cd44fc6cb811431f8a141611353bd10e739803aa1e75735215be7696864f0bf2ad50d91b6cc70ab5d63106b86b1cc506c40e40ab9961245f10183bae52b4a058f7b63da998dc6a3555e3a43805e1074097a99c5732e11b550084475dcb17f0c5c8a10696d41ba007896bc324a90f76220cf483ee7137353761014ae85132fbaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416dbe64200847686770ceb3a9027c59d8fea27f60075f4dfaa529ff4bac7c75022301e3ece841aa31f732658d6e19504c13b8c3fd06860ecc942a7d06ecc50879643ee96b338a23e3912ca5d911c14b76928659a6b3552a5bc3abe97716cd41a4d0414f808d74615c6a1254ff099ac001358898456ee2c1a54f81f6121520b0260a5833669223294e3e39e276ad21e040ddc33e995fdf137d135633dd51bcdde90c9b9fe270ba2216be642a99415b99c203bbef149243dd22617a2aa7faa1d149c9b4dd89e70da42bb301c0de666ecc8618142b361fe8504ccafc54010ce11c9172d8e13fd44239380988e2e87409cb3ed62c8cabc0eb79e6ca9cf6732c776c3f344e97f69141d88784a2c9f3b88334a70c8043808a2cb1d5a04c94edc5d748bcf77de1f496eeb4525f602187cdb005a7e89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16091aae987d1c5753f9728025c24b22e3ea27f60075f4dfaa529ff4bac7c75022301e3ece841aa31f732658d6e19504c13b8c3fd06860ecc942a7d06ecc50879643ee96b338a23e3912ca5d911c14b76928659a6b3552a5bc3abe97716cd41a4d0414f808d74615c6a1254ff099ac001358898456ee2c1a54f81f6121520b0260a5833669223294e3e39e276ad21e040ddc33e995fdf137d135633dd51bcdde90c9b9fe270ba2216be642a99415b99c203bbef149243dd22617a2aa7faa1d149c9b4dd89e70da42bb301c0de666ecc8618142b361fe8504ccafc54010ce11c9172d8e13fd44239380988e2e87409cb3ed62c8cabc0eb79e6ca9cf6732c776c3f344e97f69141d88784a2c9f3b88334a70c8043808a2cb1d5a04c94edc5d748bcf77de1f496eeb4525f602187cdb005a7e89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16ea53716efff850eb0debb9ee870289661a34cc2e2c4593d841cd26d3b102bdd3399c2088a6dbb628d0a0549058a1322fb86a4e2f9199ed22d136aba7a58d59d5c38077a1724b6cb5a51f2fdc800b48327ba79ee6307e9e224bd2ef5177e6854e42991b6c12411a2fc2b0cc018536125a4580cfa81d70a493c42e89197c2ccd7fceb3bf48b697e4e3e5918b6ce96e03c119585542c976762f2dcc182e2f8e56d9bb866ac7ef32781d77459f0bcf1d05a054d12f7afcfaeeb0e3b2ccdc50b57bedc20f0348fba9db52025fd64090e6423004098af094a44bf703550468a647488bac093cbb53712a6a6cd33ee3780f302eaaf0b97e7a75f83ba48deb7b53909a14387b3c66577d13d5f45b286c1482d970f6406bf1b06319eeb805f961877baed06220cf483ee7137353761014ae85132fbaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4161731adaddda12fdbb5dfaaa1c83161490499820b36361ed612103015971a82ea369cd3cc89608c1b10a49592253da688a759ecad7383f6a9e26d7ad5448314109d5e71bfa1ca6a105fda857ca3c272cb926b311d65917da937ed736932b8525a32bc3507da943c7f7c0770906f94d0732afa7910a69834353a23767ab66bcad060e7f6e53ad19e7e84a82952d68b03d7599507427860f705f15cda1768f48472b0602d48d31b23011a1c25bb4a019cf727e71d876e5f31b6d98c4048b5c4599438d792c5216ff491d87b9b2f0d45b6dcbebf1a6a8744b9bb44a996b55f3e05dcff08ee1913d4810c8beb99394c1ac118305f70a1fa2b0dc5861eff5a8c2eac4325e4517de334d5668d242bc9864a20ceb8d4dd7835d62f358ee6afcd81cd9ae082dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4160dc03a1e5355d914ab281720ae1746a87690f2c2cf2a6fac6ee9101e146c82ea00faf6a403162261805d69044a9e958a7eb83b8318dd86b75aeed488f39faf1e9d5e71bfa1ca6a105fda857ca3c272cb926b311d65917da937ed736932b8525a32bc3507da943c7f7c0770906f94d0732afa7910a69834353a23767ab66bcad060e7f6e53ad19e7e84a82952d68b03d7599507427860f705f15cda1768f48472b0602d48d31b23011a1c25bb4a019cf727e71d876e5f31b6d98c4048b5c4599438d792c5216ff491d87b9b2f0d45b6dcbebf1a6a8744b9bb44a996b55f3e05dcff08ee1913d4810c8beb99394c1ac118305f70a1fa2b0dc5861eff5a8c2eac4325e4517de334d5668d242bc9864a20ceb8d4dd7835d62f358ee6afcd81cd9ae082dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416a498d0a0e49d17fb8ed20f18ed36b5a1c2053fc7853db251e59686ae58b446626898ba56e2718f1d122f51a050153e1d8b29e87601e66f12738c0d9d4e7f333ccadb96f821697c54f64e40f766c194e86472c99c84fe5772c8b19fc9cbc2f01b6a8811e63e2927f8050b55e3924ca54caefd693d03a9f7278b52d4742d865a136d4679935e592d103ba744fdee65ae3f809eeda97d04bce52c83879fc7665eae3016d60e3eea26be4be5813d1bc8b62bd19f7a91baf80f29a7d1696af9f706a53669fe83167b231bd094a6b38196fcb7434ee799373d714a5249f4f8ceef29a5888ff2f8ffb378199d61dc83fcbcdf96292f3e470303d81b98b04b1ccb171406caf868703fc3995a140f834ebc1f461b71d89e3150fbadf81a8d31ca92d6b35848e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416898723a0ac2c8fa220e6fe2457e0ef855fce5c6b80dd5fd9d5a1f158ebd389d4d0d06843e8a4625f1eaa9123b8f42d8725009bbf82967687d70307e41735ff1dd5a6cb2347ad5cf2342e12c2d34db24dda1b571cabf25470efc9b1e75e1360f26df7b333845de27be66e1d8452f4104ccc36441f872024cb0575ad45a4835746ca2dd9a2252d259996cbe7edd2ab62e1d0d0c0b95f93ea4ad5a8611a4c37e599b351436e3f981ab6c5291942ef24862febaca41bc81e69d3ef926ec416ab030a52e75c3321fdf4c0963a101eee77bd9c9bdf91711b610409bfa40c20fbeab89baf97bf6894557beb489be92778cb928c549ef7aba9a17c3650184d0014da4a249e5a59d9fcacb1b14e9a6aba8e4c00774e75a3e4e3a9f8bf861d08a8a3b287b3790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16ef1aaed50bd33c953b14deda27fb79835fce5c6b80dd5fd9d5a1f158ebd389d4d0d06843e8a4625f1eaa9123b8f42d8725009bbf82967687d70307e41735ff1dd5a6cb2347ad5cf2342e12c2d34db24dda1b571cabf25470efc9b1e75e1360f26df7b333845de27be66e1d8452f4104ccc36441f872024cb0575ad45a4835746ca2dd9a2252d259996cbe7edd2ab62e1d0d0c0b95f93ea4ad5a8611a4c37e599b351436e3f981ab6c5291942ef24862febaca41bc81e69d3ef926ec416ab030a52e75c3321fdf4c0963a101eee77bd9c9bdf91711b610409bfa40c20fbeab89baf97bf6894557beb489be92778cb928c549ef7aba9a17c3650184d0014da4a249e5a59d9fcacb1b14e9a6aba8e4c00774e75a3e4e3a9f8bf861d08a8a3b287b3790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1635afda08f9d787a98144f03b953dd0a89649b46fcae8371aa6ee29aef1b788c3e2efb8a98f92fba199b37d5dd0c1fe21e2b9afc17e18ffd9a2f64b4abaf24979eae917bd0ca08b5d55fba78fd5a5436812632dda586f235ba133c7740a5b0aab34bee3f77e7918d4f0a221fe02d5033be0b44d78d8ddfd1c45517ad66add95f91e16d1907c7e87cab915683c248de575b78d9e5d79f2435c59d3507b11e2bc2443625de6cda1d79a30489b68ad1f76afea9f0ce6d8e5415ac53068edf988b0fa445112b1647e022a2c812a1229f1270c1547e04338357ee118cecb81ce6a5e61e750ad5d8a04f31b1b2f5e0a3af502bf2df55bcc21531e9cbca02abca57a24f6f3fb4f77a6c79e9a3e7a3d4736030a709eb919340d23d96ea36eac8c9f55208558f8581c29d6bd9badee09b84991852da2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16ccc135b7dc660be984b9c6f3f4344a9bbf0b06a44e78188420870be0292479edd4c39323f31cabcb2bb1391a9ec99c75f5e94a828aeb5e61e7e27b9672eb5ef1a8808eec559b7cebdc95a08ba62ef84b99821225b617759af24af09a1c9c461e3da93beece714199b371a6d76abcb2248d63281d93edab8b5880d54fbbe75c37773b3eac4f9f662690ca3bbbf7698bbb464ca4ce30adbb295c92428868766f3e1bfb9f720ee2450480cd2745a0011fab5ae1ea2abf6b50ad6b5048d7715ea73838073ae0319f8d43fbf34ebe60f8e174de73f0f42599206e53e150348f08fe4ad4b6d9eccbc38f98cfa242e231a9c2707747bce71f17b02feb60cc5ad9338d855ff46211118259c9b4f7807c501f6fbfe010afe0c54ae16877a8f7763ae52d2ac41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416f12d69d3e58fe40f13a07ee7b64880ff992ab09c4af13287ad9212e5e36fb702d4c39323f31cabcb2bb1391a9ec99c75f5e94a828aeb5e61e7e27b9672eb5ef1a8808eec559b7cebdc95a08ba62ef84b99821225b617759af24af09a1c9c461e3da93beece714199b371a6d76abcb2248d63281d93edab8b5880d54fbbe75c37773b3eac4f9f662690ca3bbbf7698bbb464ca4ce30adbb295c92428868766f3e1bfb9f720ee2450480cd2745a0011fab5ae1ea2abf6b50ad6b5048d7715ea73838073ae0319f8d43fbf34ebe60f8e174de73f0f42599206e53e150348f08fe4ad4b6d9eccbc38f98cfa242e231a9c2707747bce71f17b02feb60cc5ad9338d855ff46211118259c9b4f7807c501f6fbfe010afe0c54ae16877a8f7763ae52d2ac41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416f62c8e234ff5cf08ecf6accb0f425560577f0a2e01e8d3707f9c8a851cfedd595d78070ee22a43428e12a8ba15711f85efe774267d647265baaef766987aa85b5fc73b89ee3435c33af7b6fd20032cbf13db554a12fd612a7616b9b78a28d74eecddbbb81ad1e6ac4e03c670f81199ce9fbe0502760259a2c5df4fd62386070466fe0e6fd22149b47d4f6aac4c4b338542698165f83bbc4be9d8d71ed9907eb09a48151e5512fd09e3dd0f83c9f0e60c9bd287fa682c019b5f2f05dcb689f1afaa84968179be6c0714f1c13206782e0a7dc22e93427d2e354505432f58658c3419e671d01c04568a88679f3ab27d921ec0f0e9dd862dbb05415130ac071d8608b977b0e33b8766e2dd46d9dd347ec06f61f78b81f89ec2fffd912a297bf555a96f94f251d28d300d2e3065f07ba61d1d89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16c7d6546a34e64e599ff2792f97ff89354b6c844268e0089e4638d28c4f43d678c6f4c2753c6fe6dc5ec3c239e84537b037529ebaf576c77d3fb4c44e4f443890002a75f357afd51c36fc8c068ad558b4f9de66fe8cf8be828e7c1346f24b83d6e4c403761d6c0d9398c1a8120f439bcf4b79f8c7e5ba8166df032c7c47494f62f8c1a81ff948488dec8d3296d7d76343ee73436153a6adca26d640f9bc4150a917529f20a1c7b13e57dce9e7d7431d5e78cb0046e02f7d6e853c0241111c23ffab9ab56ab45a94bc47321678bb49f49dc827b19224b1bc13e92fe0d997cdc7d552d82d90bbbdcc7509e7bc8d27d58c52d8a411b53bf057988c332022f8d8413ea3a3871c6b1b49b640482e97e5ec6d61432d11633ced1a5523ecd3dd5ecfe73713a152183991c8448792b4b2616b663267c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416060e38d98974c24b2e09115e64d88ff14b6c844268e0089e4638d28c4f43d678c6f4c2753c6fe6dc5ec3c239e84537b037529ebaf576c77d3fb4c44e4f443890002a75f357afd51c36fc8c068ad558b4f9de66fe8cf8be828e7c1346f24b83d6e4c403761d6c0d9398c1a8120f439bcf4b79f8c7e5ba8166df032c7c47494f62f8c1a81ff948488dec8d3296d7d76343ee73436153a6adca26d640f9bc4150a917529f20a1c7b13e57dce9e7d7431d5e78cb0046e02f7d6e853c0241111c23ffab9ab56ab45a94bc47321678bb49f49dc827b19224b1bc13e92fe0d997cdc7d552d82d90bbbdcc7509e7bc8d27d58c52d8a411b53bf057988c332022f8d8413ea3a3871c6b1b49b640482e97e5ec6d61432d11633ced1a5523ecd3dd5ecfe73713a152183991c8448792b4b2616b663267c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416b2da601fe15a90594cfe5221792bc87280465ab74d818dc314b3718ef56cecb2fc92d4e429662fe95b145170a5050c52e6af622dea54ffce2e0c7da9650bfe5733d345865e297319f8b38ec98259ae94eb02ae2f86cc7491699e4917c2babad537397eb3d269fb340d75c62a7355adc8aaaf38743031b4dc2efcb8089610d027903ae00c15429b03d2fe941d028ac62d850566edcd12a32433b8b20c8886560a97a019a286aa84ac4b80c4a9eb97bdc9fbad70a4e3582278a7f147ae2db1b2ff3e9c0d753bc8d4f0443f3293522fbd31d1d2e9b707b40f6fda24e9cf638dca5ee15fba7ea9a95ee7289b605dc2a95d8e54e51c802a712ba3cfa1ba0bad249f64da243e5e225df8f82607f295eb3048fdd6ca83d3ce3518d122f92e337655b29c13a152183991c8448792b4b2616b663267c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416e62204867a73e2f1830506a7848bbad71481e5f70aa35a35503f4b9c0cc6d849a2eba78027645adf91a9f794b3a0dda3e8c23cc6bfc6218cefe8afdd8f733eb57a18e9c20baa4434658e32135ef2972d19ad72c1fb1642854563162b596354e24200de4848a200a76c9221cf067f0d8347d46003475c2377997ec909faafc4a5cb1d6aa9275a8fdcb6a21b0cf12845667a56bf37c45688f23a531fb971da6c1324493583a5131ab1959d926e78831ccf32bdb8961e9456d552a13bafd4494a6b28fcadaeff319032c2f0271e5a0b416dd4521203c144db95f9398cb8af8cb72e29fe24e6789fa8ff90736cf8536a4475751ec40d3b6cac94cc4b601c21e6664367f32631c4a645972d8f7fa19bea1cd7535b272c101f94871be988774568dbdae6e1711f5af0f03c6d2054ec4c2c820ed8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb164fa7cadcc4d5cc416f054b3a253cfa241481e5f70aa35a35503f4b9c0cc6d849a2eba78027645adf91a9f794b3a0dda3e8c23cc6bfc6218cefe8afdd8f733eb57a18e9c20baa4434658e32135ef2972d19ad72c1fb1642854563162b596354e24200de4848a200a76c9221cf067f0d8347d46003475c2377997ec909faafc4a5cb1d6aa9275a8fdcb6a21b0cf12845667a56bf37c45688f23a531fb971da6c1324493583a5131ab1959d926e78831ccf32bdb8961e9456d552a13bafd4494a6b28fcadaeff319032c2f0271e5a0b416dd4521203c144db95f9398cb8af8cb72e29fe24e6789fa8ff90736cf8536a4475751ec40d3b6cac94cc4b601c21e6664367f32631c4a645972d8f7fa19bea1cd7535b272c101f94871be988774568dbdae6e1711f5af0f03c6d2054ec4c2c820ed8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb16480c637327e2e042bb5e3799db693d2c5fd843aa7d7f736340ca5a23f732257d064362e76a1de2589c21104df95d6f52e15145ea8cd4f0fbcbdcc13854afb8bdc4991286ddd29c705d8f15a88cc8f7e1efe1b3b6e6802e9317ea29281f666b7ff59130967c774df63c8ca2ed7394b2a86698c7f027655f8906246247b5dc74fd2702f565c98028ee8cdc3b7e48204292a51d32b55eae37d00f3fe1e832aa0298693a6d7b2fa6a9072e91982b492bf7f87d31336a1e00a672b50135355dc0c66329a82986bdace9640f5eb33cfce7014dff97609b4c0a01261cbb41917156ae62e9e8bef4daed89b416a38e2c2f874561d10a70d78bd6770ad025e61c916e5dcfae4806236da0328b1f7cdb2f5ebd714fedc5fd902162be1bf1a410dadaef828bfd8cb56636f2bdc7d2a4f11d592e78809e68d3219284826158afa5fb58fda6f4cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1673b92bac75be0c0c641976792c40e498e95649e4fbf488f0d8bac9b5bda05523cc397e4efa1c7a915369eb5e0a40c2890d83841a40cc0eb4a4157be3b9fdbe18d7bb6a0bcd13ee6c716630e019eac41664570b882ac801a6d52bb488b0c418f26bd85a4d39ec988ee25146bd9977438bdac32cf3f5075fdd83ad643e8cc0e79bef78da7db5c498e13468e3ae99d23de9fbe4bfbb69c86b5e48c396459f2758a26052dbb4b61cc64d26b5bfd9d7e2b27b82046e8bf904deeb2653476cfd218610dbf2b97de05ac6872e8a96f2b5bef7f620f0509d52f6dc71548f3f5cd3b1c0c0fb38974db89e3108c9063b0cfabfaeecd4b19df8ba4c98525f642f536036fe7bc3b264f28401b132fae8a8d55525a0414e967776d8afa8d877207b445bc8d10f58f8581c29d6bd9badee09b84991852da2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16f498e960bab767c70fe969b09ca5c47c9fbaa52a48f3c08e69a775250fb17ad04563a4c1ff5b50d38d613888211360620d83841a40cc0eb4a4157be3b9fdbe18d7bb6a0bcd13ee6c716630e019eac41664570b882ac801a6d52bb488b0c418f26bd85a4d39ec988ee25146bd9977438bdac32cf3f5075fdd83ad643e8cc0e79bef78da7db5c498e13468e3ae99d23de9fbe4bfbb69c86b5e48c396459f2758a26052dbb4b61cc64d26b5bfd9d7e2b27b82046e8bf904deeb2653476cfd218610dbf2b97de05ac6872e8a96f2b5bef7f620f0509d52f6dc71548f3f5cd3b1c0c0fb38974db89e3108c9063b0cfabfaeecd4b19df8ba4c98525f642f536036fe7bc3b264f28401b132fae8a8d55525a0414e967776d8afa8d877207b445bc8d10f58f8581c29d6bd9badee09b84991852da2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb16efff4c33f4fdd02d6174177131a3e5abc58d0b86581cfbf284049bac15d1fb84042d70dcf8213b8c6e16ca2a5f82284f3fdc32b24fe8fdca24527ced5ae09c74488ac8d2a5f278d66735b62ae7517fccc4745448a999f9530c9bb06deceaa8a0bd5a163045fe0564e633172ac159c054ae096929076357d66f299d3d9b8509b5a5f2797b095ab7dd9d88b81318b997e294203b44d06cf0ef08907e42420dda146aee09632962a1237fa7e816e98e3f21aa815e7102d360fded6ffd39e7f78b95889ef6a5ca45cb6795382f624f4948498d20a8202fdee068324724398f7928dbd689b9e9f60330af4081d701106b70e23999aacf4caa09eb5b52e7780971a791db14bd4ef83015f905b5d063aa8537f35721d802c3f45313de668784f2de619fc41a5f9dd8e03f2546ba6f5fdc341a2abaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c41682c5d4e3911c0e9612bfcc5f3f1c5ca80e99b956e5cfe153fd6cd55610e406dfc021443ac7e5bee7b9bb4d763c9bfdb2f1fca1b6f62dea0dc2b440d699bb4ddb9b64a6d2164ccff45bdf3d34ec0231865397219203bf1116a30c20f580e426a20e21496d8ea12f885a97d90596d975b961696fc5e195039d6c5e743625307acfd31be5c886538c14045886ae8b3bcfbc90759bf3a1e0242a2cc836fa969b5806a5d1640067fb13e24e558e9cc838b1eb7ac40a296b2b9cb16db61ff46894bff4fcb2b0a188d58e1be6b8bf45019a53fd4dcdadf939c96204280f818049f96633705217d5afc46603acf3c35ac67e3b0f83d7222907990ef0b2cfc2edb817b0edc9c2ed8576208f1c638bfb95d4cbcaec0c5c8a10696d41ba007896bc324a90f76220cf483ee7137353761014ae85132fbaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c41612a4607af60c4f6fa61ddce3b1f1a832e333012bf410d42f2883c1a92c6d2905c485ec9fbba07404e7345d69cc0e0fcff1fca1b6f62dea0dc2b440d699bb4ddb9b64a6d2164ccff45bdf3d34ec0231865397219203bf1116a30c20f580e426a20e21496d8ea12f885a97d90596d975b961696fc5e195039d6c5e743625307acfd31be5c886538c14045886ae8b3bcfbc90759bf3a1e0242a2cc836fa969b5806a5d1640067fb13e24e558e9cc838b1eb7ac40a296b2b9cb16db61ff46894bff4fcb2b0a188d58e1be6b8bf45019a53fd4dcdadf939c96204280f818049f96633705217d5afc46603acf3c35ac67e3b0f83d7222907990ef0b2cfc2edb817b0edc9c2ed8576208f1c638bfb95d4cbcaec0c5c8a10696d41ba007896bc324a90f76220cf483ee7137353761014ae85132fbaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416b96d69d657117a34e0208e8744bf43df276ca9bfb242a306c9a4e84ea8967415ea696772ea4d7d79b755232cc0a54792ca2eb6a609e583381f48621a22bbf3a646c38a6456dd8f6bc845d35da69795c61dbaabc7a9fb4fa3b28c63cf9699c986bcf9bc8ffe68c481df3d5f97c2a7c4a0a33533a21aa5291dfcffcf35ff810c387921d4ffb7c13a814767ed773173b8c29ff8326236862e24d3e4bb86a7a7a4bf82204d0487b226c20b86ff1c71174f59fcb38696d05ed5f941bcf4f6377c6b4e87dfdfa052b1932e858edd6c0b4afc2bedf54827c5a930ee329afaf2902d2c5109c6ea383afe491bb62c50fe5c369669aaf0b97e7a75f83ba48deb7b53909a14387b3c66577d13d5f45b286c1482d970f6406bf1b06319eeb805f961877baed06220cf483ee7137353761014ae85132fbaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4162852b2c0fe0d111c960fc0cbf07e575a287544ce2435e55818d75922be57958d2df01a08a7305b2e9ffda75f7866a26090f566c4b2b32d5133ffb03d982ac66726d93dc1dba828f74e15dd485ed1a2dccb5213eb73f2a2e66f43894c13cc7fe298d9aaa1e76259e398ef10266c41a7717501d8f2c158a89093692f3e223537f7e9311c1656dd80ec7c27d9b9b9cd0f1a5b8ef63297ec8c5905032b39957e1e0ef489d81c5884222ccacab7be44089f3d528b3b6ea4be00b8e3a064faa1e5f9aa0b5ce5987780c279e3bfdfe838265dd68f65ac8fcf67f9f57cd56e572bff7cf8e2c83d7886400fd8b5993b458bfdcbb535d45a0b856193d48a9dced9a7be73dcd27d38cb20e9a2c225fe5ec9c0cb1d581ea2d22103d999ae9e2981bfabf39b4777de1f496eeb4525f602187cdb005a7e89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb163ffed2b8bb98ec305a51844591c4b4b781548145a8a496a1ac8765730e0d9d072df01a08a7305b2e9ffda75f7866a26090f566c4b2b32d5133ffb03d982ac66726d93dc1dba828f74e15dd485ed1a2dccb5213eb73f2a2e66f43894c13cc7fe298d9aaa1e76259e398ef10266c41a7717501d8f2c158a89093692f3e223537f7e9311c1656dd80ec7c27d9b9b9cd0f1a5b8ef63297ec8c5905032b39957e1e0ef489d81c5884222ccacab7be44089f3d528b3b6ea4be00b8e3a064faa1e5f9aa0b5ce5987780c279e3bfdfe838265dd68f65ac8fcf67f9f57cd56e572bff7cf8e2c83d7886400fd8b5993b458bfdcbb535d45a0b856193d48a9dced9a7be73dcd27d38cb20e9a2c225fe5ec9c0cb1d581ea2d22103d999ae9e2981bfabf39b4777de1f496eeb4525f602187cdb005a7e89780d9be45c994234ba2860a557df5480acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb167798c6544dc21423f5eb262ddaaa2e090856cec80bb311318d9639f2ab4ca7f0d32165233ebcd975d21eea69f92221b7fc8948d3d7c54575d3678a85aba174b317a4cb1e4d1c2a87d909f79de758a0f27681ab777068b1fc544ccee1499d096b26352c1bbca74628bc6c69dddad315f3eae9a66f64035887134e45f98cc27c0e09547fe55a78b5cdb5285f1148a7c7a431292b18c10911ddd180ddd3331ada8e466f4f5e12dfb7974d29fa2cdc4115582bb8c4fd6f86aa641035c43e343a437eb8e185cd5967d1269a30eb713cc15fa1905f21968ff9206773921bc2057ca905bdd16a3b94612f7df2bf848415cde986a6ff90656f3b91b0d0d68a49e919f7df142aa7674d60f5b81d415dc4013eca4471d89e3150fbadf81a8d31ca92d6b35848e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416f3433e773b58014065e42c99bb37fe8016a66ac096f6594ff6a6bd029aa57e5d8ca89abef7d09d0e8df33789e2e355094e3c5974000df71e22c4a275f211fe30418bb77ed96e33ae3061426243c3a38647db29b324649950a6987aa5551db2395ccd20abec9a2cabba681fbfec1269527453e7b663d44e48fbab46ab91b0ed9bd507975fea5e32cfae12f385f7b32cf23142a967979b3aa65412a8c4a4044e27dcb14ae60149bdabc154d7f8a4c5e6b11e056ce5670b2cdee9da6bf4ff7f38200916d23e8e3e9df5b3d8b598d93e8de0208ef77cb293a0f0932bf3b06f6249b529fe24e6789fa8ff90736cf8536a4475751ec40d3b6cac94cc4b601c21e6664367f32631c4a645972d8f7fa19bea1cd7535b272c101f94871be988774568dbdae6e1711f5af0f03c6d2054ec4c2c820ed8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1674dd07596700fd043fc45304fb0f388c16a66ac096f6594ff6a6bd029aa57e5d8ca89abef7d09d0e8df33789e2e355094e3c5974000df71e22c4a275f211fe30418bb77ed96e33ae3061426243c3a38647db29b324649950a6987aa5551db2395ccd20abec9a2cabba681fbfec1269527453e7b663d44e48fbab46ab91b0ed9bd507975fea5e32cfae12f385f7b32cf23142a967979b3aa65412a8c4a4044e27dcb14ae60149bdabc154d7f8a4c5e6b11e056ce5670b2cdee9da6bf4ff7f38200916d23e8e3e9df5b3d8b598d93e8de0208ef77cb293a0f0932bf3b06f6249b529fe24e6789fa8ff90736cf8536a4475751ec40d3b6cac94cc4b601c21e6664367f32631c4a645972d8f7fa19bea1cd7535b272c101f94871be988774568dbdae6e1711f5af0f03c6d2054ec4c2c820ed8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb166d692390884d985559d636b41089d858b8fbd66fee6d011257cc70362304d62bdd6084d3bc5eaad3b395f2806d92ec67ea1fa43ea6a1a6e36ef11f0c7bff1f813d240ebd848bb369991d827c3692b328fb0639eb46f395fefebda615359732357c865ea7b25c13adb3d734c9718b3aa5d32bc341b0aba5125f6c19b4cdedb323113fc9dfcd989a9240e8e0fe311e6b63123449fd5f6b7dd6da876f28a1f8212e8cc050a97dd2122ae778dc6034b16b7c171d89d47c3b99a0a8637543baf1d8320466ac4c18c2b9250d5a9bb0137794bb025bbfde0fa8f8bc3b6edccc874526d1993b240e31a49d937649403571718bbd8384df9e605561a4ca0170c617ee169d4d5f2ded69604dabcdab7e10a2ace1facdfa6f2db7a0fe47fac5588be5c2abdf82dc877e6a67768ea3a6a5e7df0926f5f538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416c07b7bc4d5da74f58e60c4b36c51a5ae7c41f5113bda6776d725c0eb254e5006a57a40144d8de85c65d28418bf397d5fe11da9b257baa255cfbe5ae58c946e1cc4eeec28dae986f0dae63e203bbc5cdb84cfecb032b8cf8dab578966b721c9b462477958cc5119a1741e366167dc7562f653af4a57143ff222235bda0b2800d1cac4aabda65b6d6170edf3f21949daef6f05a93a699e900cf888f75171bac7e051a9a271e5006220ef32f9347876815efac543bca54e7ca63e4c981965a7773d9411e913277b7e38abca61b76b2bf494bb355d08b700b8cc3cda75f09dd21c6a8ece5f712b4522d5abaa8b9835a583635e3ab02740fde48b4a49bc13c7f0de189e5a59d9fcacb1b14e9a6aba8e4c00774e75a3e4e3a9f8bf861d08a8a3b287b3790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1689a56937c840d3cf800d944c9e9ee1a2a25cccbab9f273bb450488325bea39dd7221300c859fa0cf326fbb8b8209deefb47a26142a3007eab96f52341c5536123418c4611b88e80043dde607b58f35ed025085debfdff2509653c86e1e3227e4ee6640f286329130d2a94b02d679cdafe3ae8753c87eb559d0c151e5ece0f93b6aff13c57de2414e3a96540ac5ba428b3d28756c3dffd8e53b44da92c12d5cbc51a9a271e5006220ef32f9347876815efac543bca54e7ca63e4c981965a7773d9411e913277b7e38abca61b76b2bf494bb355d08b700b8cc3cda75f09dd21c6a8ece5f712b4522d5abaa8b9835a583635e3ab02740fde48b4a49bc13c7f0de189e5a59d9fcacb1b14e9a6aba8e4c00774e75a3e4e3a9f8bf861d08a8a3b287b3790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb169a74d31c7187258a54143bc90610e9bab5974413a9bec9535efe426674867faeca387866d14012d5dae464c125aadecad12cd9c989f0cb2cc3845742952c7656addc67b46d10015827c50135f20248361860683c2013066f9e03c85fde163f1466af339d450f16a8e6c2d161664f3cfc86a3ccb673e911f7f83115a57c74d59561537f0ff8fc6c44d23bbfccaf469270aa767bbc8114d623abff0fc4ba90d2f9abf1a5791d55c9b5419ce1f6789901f0c9932c20357ea733e6feea836ee00b66a32c51959e1c0cdee9bb93847325d937e261de0f9802299c6905d13e90d91c3bfe6edd3e4c3fc2c0ec981693169d6df9d8a411b53bf057988c332022f8d8413ea3a3871c6b1b49b640482e97e5ec6d61432d11633ced1a5523ecd3dd5ecfe73713a152183991c8448792b4b2616b663267c2b02fde903a824ff26cc3cb42b6e5c424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416d2268db8a4b912ec435f55069a792a0f8c4d4734082311b617af543303fcdfd5b4b722be487d12214e8f9c77a7ff560b5d9aaed55d426dc86d2e3b7de13a217b166c46f29518f3453f281fb8e9dd3e60d69fe051db7d9d11a0defb4b0b29c1e22a4ff77ef8a234e8ae23c92b28709d4e5beabb834a9c4b83d31943cf7a202baaf5b9756a3fe878a2e4871eec613fac77a30d172b851d1378ad297ecb54584d8cbb92f3c9e043fa2a83bebbfd8db5f17a135bcab5594b8f4b610b349dd5be26439008b8ef97ea16bfe2adc33acb3db84b446a053dbb0ee453e794b40256bd17f1e6b0cdb5648c6a160723a4d6a8fdd0a6d61d83da410476c295dc9802b5d88761caf868703fc3995a140f834ebc1f461b71d89e3150fbadf81a8d31ca92d6b35848e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c41661195350ac6ae5d1646a28b669befde38c4d4734082311b617af543303fcdfd5b4b722be487d12214e8f9c77a7ff560b5d9aaed55d426dc86d2e3b7de13a217b166c46f29518f3453f281fb8e9dd3e60d69fe051db7d9d11a0defb4b0b29c1e22a4ff77ef8a234e8ae23c92b28709d4e5beabb834a9c4b83d31943cf7a202baaf5b9756a3fe878a2e4871eec613fac77a30d172b851d1378ad297ecb54584d8cbb92f3c9e043fa2a83bebbfd8db5f17a135bcab5594b8f4b610b349dd5be26439008b8ef97ea16bfe2adc33acb3db84b446a053dbb0ee453e794b40256bd17f1e6b0cdb5648c6a160723a4d6a8fdd0a6d61d83da410476c295dc9802b5d88761caf868703fc3995a140f834ebc1f461b71d89e3150fbadf81a8d31ca92d6b35848e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c416741f61a3af0be3a4b7fe76b01adb32e2a8631786e1a6d327c035c14d8da3d32bd44efbb5acb1c0c1d6b1d15fcc2cf0fa8e7e52c8f3b99e67de88d7964df1d14271378302386a19fa534c259c6f5674b9c7d03e03c48013fefe8d609b4a74ee0d2a4ff77ef8a234e8ae23c92b28709d4e5beabb834a9c4b83d31943cf7a202baaf5b9756a3fe878a2e4871eec613fac77a30d172b851d1378ad297ecb54584d8cbb92f3c9e043fa2a83bebbfd8db5f17a135bcab5594b8f4b610b349dd5be26439008b8ef97ea16bfe2adc33acb3db84b446a053dbb0ee453e794b40256bd17f1e6b0cdb5648c6a160723a4d6a8fdd0a6d61d83da410476c295dc9802b5d88761caf868703fc3995a140f834ebc1f461b71d89e3150fbadf81a8d31ca92d6b35848e9c5db10854e5628cf24f247dd729df538b680a661c9ab745767b28606891940cb976ee93b4b7999c6b4703f18e525f7f6d5fa682ca0a7fef2c133d9bbb6c4169959d649c9aed2c4511da25aa9eae3c9c27270c3b4f0014931962db91df6ef4e2d359b3eb5c0ca105327583284c6c46be060f37e31f08efcef35f370d781ba743d8037f26e3ced3cccf3b2f79a14c8b35c30c0ca43d2d09d766787b390eb025ef73ac4d826aa6e69cd12154310285984e75c3255e1acf1d2640bae3799761d4cda437f05f3de3ed2659d415c67f5947088e809010e5e402ca36ce30e5a980c1c1328240f3212539f0a20d9894c523c99c3c5388288f5a038dcfdfdd5fd9ee2b31377fd6136883aa3da0438bb3e7099a34b3af7afbbfca1c83c9ddb0c8bcd4e2dac093cbb53712a6a6cd33ee3780f302eaaf0b97e7a75f83ba48deb7b53909a14387b3c66577d13d5f45b286c1482d970f6406bf1b06319eeb805f961877baed06220cf483ee7137353761014ae85132fbaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4160155c3358ce83206f590f84e00155474c27270c3b4f0014931962db91df6ef4e2d359b3eb5c0ca105327583284c6c46be060f37e31f08efcef35f370d781ba743d8037f26e3ced3cccf3b2f79a14c8b35c30c0ca43d2d09d766787b390eb025ef73ac4d826aa6e69cd12154310285984e75c3255e1acf1d2640bae3799761d4cda437f05f3de3ed2659d415c67f5947088e809010e5e402ca36ce30e5a980c1c1328240f3212539f0a20d9894c523c99c3c5388288f5a038dcfdfdd5fd9ee2b31377fd6136883aa3da0438bb3e7099a34b3af7afbbfca1c83c9ddb0c8bcd4e2dac093cbb53712a6a6cd33ee3780f302eaaf0b97e7a75f83ba48deb7b53909a14387b3c66577d13d5f45b286c1482d970f6406bf1b06319eeb805f961877baed06220cf483ee7137353761014ae85132fbaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c4162ea7a0ef554e6a8afdf396da404fd9354bdec53973c594aaee87ccc469a7e184ce2250877e6c27b9a5cc246bae3350b01d22ce4d6ecc8c9d2782bf78198c8e1772c81a16ef43e8ea9c07d926a1b09f0d97f9ffd34ac1109de9d589c22d8ff7e88b214978300b0724d68c47f65771e7095ee2be1594367a756cb8a286ec59f360a0b677ce98dede5c7e85fff65cb48e4b56f316fbd4815612c71c731ebbedcd08daa1fc88a315e9484fc65cd26d8fc6c326f97bf1861570ad59a7557bcf8a3660ea019b668c0cca0e0ef31260cf57bfdd04098af094a44bf703550468a647488bac093cbb53712a6a6cd33ee3780f302eaaf0b97e7a75f83ba48deb7b53909a14387b3c66577d13d5f45b286c1482d970f6406bf1b06319eeb805f961877baed06220cf483ee7137353761014ae85132fbaf0cc395e91b5bd2355e2c8b089f05ac424a6dc292448e1b8fa8edec66a8515f7f6d5fa682ca0a7fef2c133d9bbb6c416d087cf2077f7216e3ccbfb63bf6fcd624a5ccde71e7c9f4db87a40fac11268daa45dccda68bfb0d7870061a1e2616e397cd10722b83b89ae0015bc29aef641649192b517206bef151d0d853783e6e980451f78ebd987a3959b069821a0f9d91928a82ddb87e0676d6c45d6ca6f15fbdfec069ab33af688dc87f7a9a43efd63627780426ef70a0b38d134f6682c8639f32bb0dd46e1999acd6ade38fad1b9e13ac34446110349d58f23188c754ed85bcb81a6dda6735d9d8630337861add76a16e7cf3cf6ee64c13d7992aeb34da27de70076998b3a43cd17ed36ba395010621e92a43fe278c59b452d37259b750f0a13d53cd1e07e30d6c01c4f20bd1b90925ac717d4ed6aaa99ea1d572c0b4c32415bb2cbfe61c201fa00497c7105863f05e6790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1603527779007a21615ccb4d1347b48b6a7696d11dceff5cae669f46e5e29f30f9ade64b3462408db380b052469022888e404ff58731f9e38ca6c21de64d939463e7d6905a6a234e17ece244ba502300b920c5e11d3952a72cb8534bb507eb83d628a82ddb87e0676d6c45d6ca6f15fbdfec069ab33af688dc87f7a9a43efd63627780426ef70a0b38d134f6682c8639f32bb0dd46e1999acd6ade38fad1b9e13ac34446110349d58f23188c754ed85bcb81a6dda6735d9d8630337861add76a16e7cf3cf6ee64c13d7992aeb34da27de70076998b3a43cd17ed36ba395010621e92a43fe278c59b452d37259b750f0a13d53cd1e07e30d6c01c4f20bd1b90925ac717d4ed6aaa99ea1d572c0b4c32415bb2cbfe61c201fa00497c7105863f05e6790460159356d0dfdb7ec99efcae87ebd8f18d95ba9d1d7dae003fb01e0c5643cf77146942da5135d66d0adb4f7de0164d71984f12f532154149b6b1eb601cdb1686d575832ec6979067edced8b8d9894f9c300e3fcf69e47aab1b4a8c34e78c0b327900c255e7427f9bd222f2067c430358689d9721d596f4ecf8474a6dc51276e72f9eed3d8b56257aa6ac3e23189342f57bf13877f59a0c3bbb7dc28095866c1f1b5e11495582c544acd00efae64449ae469acb52621121657f4b03fc6aba32a22e16422c0880f9177e73906e0a0fac17d80b4871398c6d425dd603ce76185c875d7ba6ab74452a1a83af24643fd9b19c7c53acf7bb3a3abe11f418e2881150deacb775ad79984c9b60acec1265ea3a7d42adc17216671ab63678f6a7793387fe5889540b50049b7063a6cfbc27b07d2df55bcc21531e9cbca02abca57a24f6f3fb4f77a6c79e9a3e7a3d4736030a709eb919340d23d96ea36eac8c9f55208558f8581c29d6bd9badee09b84991852da2ebcc94f98a7208991fe008ac4824fe80acc7ff5298b77a1450ebe83bfea9df4d71984f12f532154149b6b1eb601cdb0303000500010000000000000000000000000000000000000000000000000000000000000000ffffffff050294140101ffffffff07004e725300000000232102ee8923306e8e9199f3f6902f05ebaa1a622054b9a67c5dd56869f464ea31545bac80f0fa02000000001976a914296134d2415bf1f2b518b3f673816d7e603b160088ac80f0fa02000000001976a914e1e1dc06a889c1b6d3eb00eef7a96f6a7cfb884888ac80f0fa02000000001976a914ab03ecfddee6330497be894d16c29ae341c123aa88ac80d1f008000000001976a9144281a58a1d5b2d3285e00cb45a8492debbdad4c588ac80f0fa02000000001976a9141fd264c0bb53bd9fef18e2248ddf1383d6e811ae88ac8017b42c000000001976a91471a3892d164ffa3829078bf9ad5f114a3908ce5588ac000000002601009414000037ff812f8ad1814d4acff9c45457873553fa2ef12602c1386ec6894e7fbb9b9503000600000000000000fd4901010094140000010001eb44148fbbe7c36e1406ea68701f9b9dd5e10f3aeecbbad9885d73846bf0891932000000000000003200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000600000000000000fd3f01010094140000010004eb44148fbbe7c36e1406ea68701f9b9dd5e10f3aeecbbad9885d73846bf089190aff030aff030777ddfc49dcb9455339f817bfcff959cead75fa408388149a3cdf8e0bf71b575cf072a408f30d58ddac49f1a5be7e05f6a7e9bd2a6ef49d557ea631a4fed33381d59231ae3821443357cca68992d2a507a0671bfdea02b8b8e12b62620ee75ddb03dccd25f91247c52afaa6f25d5fe58862f3f1abf25ba133f923604adaccaf040119bfac0c7c29488fc4ad53829fdbee03d275f3b5aa8c82bca19d90596311236534d2fd36b750d8492dac57e1e8d31645a81ab12eb4e7b56c8d23da872b2fde68fce8ccdcb2951da637824afc33fd9249327a24cfd214776f1dd3b353754c0ad4f803bec986974507bad1e7b48a4aeeea19405c4488ec2841db9845c1e311ac11a9e380ef62b001268149a0d38a9e \ No newline at end of file diff --git a/bchain/coins/firo/testdata/rawspend.hex b/bchain/coins/firo/testdata/rawspend.hex new file mode 100644 index 0000000000..38d168865b --- /dev/null +++ b/bchain/coins/firo/testdata/rawspend.hex @@ -0,0 +1 @@ +c202b25c3200000046551190596d29fb87ee282c1e2204bee5aeb7a1b1c1c28f1d507ca1b5d4f4a351f4af3663d653f8b1061fc77b2b7f72c168414574007b360b3c59f2dddc39519ec1ab30bf290181d1dcd37f4a1e35a24d64937a05be7efbba8c418fe877092be132ec83c77c4098f059ddf947e1aec7e64022acc17bf8cfced88d37da3cb2b2e0105c555a26e42f89f842b219d60ef390a8e998967adf46f06900dd42059810b56112cb23660ed591f4de1eea034fe181a6b1a8285e35212cbc3e0c3f29a138ff6aae9c91ea7abf4e20ce2dd27d7182696963ba53fa57d1eaceafbef2cc814d0b17b19b560a48cfee21fd69025902c23b8ea9fab931a60cf041c09418560020d47a746358826da947e16206a1d35d9879a9d785988bf300a1ee6641d12fea79a3991102d6d8f9b628e5402b0c357de333f9d752df7288ae0e8a60ab910694ee28a04889c52ab6eabc8b890c93fd8129d211357013ead3a8603be4843460cb25856936078045b5b07d1e2570fc2d0f45341827642c3a725a86e07352b2b8f52748e2be7adcfadde26eb9508a93fc5305551b9fda4fa819c1256d868c9b01857bc3a5ef1db57b6351557a53c1409425343abc40754cd121920eb99c92c711c730d838a129b801b2b152ff3b940c83c70addee716160951503eba21720f9859454cab7785cd7f25ecf3846cca6e6c92dd993268c268a3cd1f3d3c3818687f50f5423e658ebb7afdf3f6de96baf2e61b344103c2d16f20e31873d30b38e4a19856a8f510f98e74b819de5f2d208ede4bb3066e8a91d71f4a68f5901755a5faaf54a68316a09fd835f495018f2455f01b6470f8be72360d18baec83e89ed5064a87dd0cee41f57d09f87eecc3dc012f4d2d316544126959484d625a7922f288e1699a5b5b672c44cfaf1ceefd0b4683b1e7a62e9a33bf32412f1a49f1f8a0570dcfee53b9db948e35b9cd545e74e0d024ceb04bf726fe3c323ce002683447beb33788180dcad0a15569e968f185b907b24f0a91a00a237d92a5c2be6d752b27e06fe7238987cf7ee3ed0415a1cd0cc69b8eb586fd6f7b83e01692d9d28b59b9c98c231eb38165d42e62c10cbe4246bfba35cac79f0e002fda3b06941f4ebadba9109d81355ca6d9b0ec463ab4f41542b9cdacbc3c7303b66e5ce54fdb33f1a4e12d069a3154df189ce2f7340d95433de251da4ddf967e000fd69022b80e7bd4378a9be93d9558d63c8b2829c80e9ba75e4603bdcd45a9e100db330dd8017a00cf3d317c770b6d6dcb05cb2cace0e296ce2e8a96b71b0b6ea48be0e2e81cb66e76713a5877020a98acea1230eed97bf80b519b5dca15f724dfc754fd3150d2056ff113c9ffca161e13603f0acdb311614a44a47a2178f46a2017e73fba20d07a1da0a9792080875aafae252a7047154ad590aa34242cc5a76c2bb97c6e1f464d65abb5be84c64589496449f08d066267af9bd40ac5b7b55160f1d2f9933ceec99b3b5a4915776c7d1f5dc2d0226c0742e0c5376bc116aa571cbb692fe53e7bd9c05aa8160d8476d40f5208abf58bae2508bdc5e52ec25fb3a037d17a162646bcf82b6c2dd8560ed86c9a67668a8ade7cce1540d7742400e05d091058fd60396dbd0ac83b54134d64f76303f022da8765a67bd00a0d178a1e97dcf747551decbae17c89c2db17de96220a82f5364504ce7114794de930a35648fbcaeabaf06a329e8e0c3c87f2cae56134acdee0d86b3941d7846e6bbe424e89d8cff510057143547dff7c06ad7326d5bed5de75ec34b3163c3c58a96cca18afe399cef35341d588ff9c15c0c8f5a5a63727ee52311e3f28e3536292ddceb48018b6035113cbb3e838c668b2725f12978e5ab9d8f808dc64ccc0ca48a02c2344e8be8689740c60cd58159e45592c55da593f5f52b1d370a5d6fc364f03fc0ac094f528a67503cbb6fe49513db62596080b728be309f4ada27ead0923de2e89ff8ccea5a00c74f7d106928214e2feeb4ca2bc475cbf3bd7b3458f4d10db64c9abc350e244922519f2d13ddcbeea3f3b2e366eeb00d9d989142faf860823fb5fac1a3e0a72a102c69bfe4ff00fd68023299eb15b9c2892d691c8f439064db72f10d485fb32bc10bedf746bdd83e33f6a56978f66b0f89427a84ffb3f2521841d75a1ef262fbad0547a76deea1151a71b9a39f0d1c8df6c0fa6a66136daafe0b4a205f84df8edb19db8cc069aad6605178c7dd49e9e1af87de1b1ede3fd1ceea73f973ece91ad8ced139754cca4cffa5597bb9fab5fab3d836ee0e04c1ba1077500cf49543bbe5c986a8194b9cb5be63721c4d597c7082d456b23a20ad036c21f416b970a344305217f455925db751f52b0559bd986dd35192f639ee698c9468ba338a7e46ac9e50368eb86e5666af8431e7ae273e14d8202a557d93e3a93cbc1261a4bb13898c9fb15ceb3211f6f7d7adaa30b4baa6c4fea881b84c43f4ee2b9a9111a55fd502fefd95501dedffebebe4fca78fff7c6dd70e90adb7b8f2f611344791968aa3a0bfa06bc759721c622c8f2a4a67851c2acdd586952b84e287f086f60540934d05faf5a267f4ba3f6c17eb15c5fe6f302094247dc9c3d1d42a0017ac8e97400361c94f01c398ad4c9c3f88e21268203e3b52086d796a7147dd039329859e618f7054ca899219485c31bbf460a1b359df1c3a025bff338a365f33f48f71763647e48cc24472edb962d435afd64f394ddab6c6f64e6f54a3568f38ae45ce599fba9314f121eb1c6b8ad3e5964557a058186829a12002b2a9220a1ab55ff478562cb333ef6bb69d4ed4dffd9ebf39ca15f5eecde297afbfd7061e17eda335cf7212389abf1fc13053298cbfd6aa6402a323d5051947347e9fba76b059206a916a4ee84ff1f48c98d9be5ace61a2fef441c44587bae69770f69567ee8f52cd91adcc76250951be53462207cf27746c225e13c2164663cb0ace257902fd5815b878e4f19ff10499acd3700828a051f8c1ec33d421135089001547dc1df5cf9a43da6877472c6496ae65ec1e7b91bc3494769a03cfc6e350c588de0045bf26d0b418e08ffdae019bfb19f510e0e530d66f8173b13826b1281575a5aa703bb86cef598a99b9546e1a241fe86acc5a8f7156542fba23ff41c1db9267708f44dbce1f75465a7befa3e135393b1d5faae4f7d90c480656b0f012d1a66a03c76a58754b22e42f234de46e7f4f05192dc734f497d7d9a1989d657fd1bdb4e2379e4f576c5ee72be808dba602fd3501319e81fe1211176143ac5d9b76a06951a6a0413db2f4ae33d0f7d9a216fe8a5c5828c5af6778cae6464dea07262b1e64f18db9daf24fae038494836e7f96f8056a42f5966ac53f1e3bd7e2a39f129ded3d223908e64e020b7df2fdc275b993ac951921549d0b1cfe6464e8a3600f21714108f5c1aacdeaffd3416e28db6321b761f973ed338e95b559ae9ff6cfcd65e62d5e92b72cb244dda8ab5babaea6b992d7dc5ddb8bcfd189b2f564de4b57e03016f578c3d0adf004232f2f2ee155af2d6d0224799732c61513f10a51405be7b07ccce65f99f0eac9e3ae73a2782e34226508fee3c4effda657412c2bfeae4e4f2b63037db545bb7353b69654dab3f5da6e05e6c801828301e705eed65de092fc7081807643d9d3a84c2c0f00e460e4a7803f8fbc60c1803783f2a2c378e07531ce57bbb700fd3401139803deba8b83a31f7a90a52292c7b44d8c854a7dcdb835a2ee349fd4034792c0e62fe57a845f2927a74f363bf8f01a8a34266c8c3901c32b69f954e08e08e455f19775d92ee0114ead8da754f4403db89cdbf7e2a26d5560b060cfcfca049fc0b4b6a284f3c8b2ca99b0a53e1fbfffe5375cdb81242e758eb5fe13482030b78cf85d1dceb18833fd999d7f2b99a59961c12b8cd5e7cf8b0aa0212334023a28dd3a1211961fc7b7d8583a35d3a89b591e085eb2c63a111dd5ed4fa7b940733658a17e4ebdfb86a9132803d71a9a8b999fd9084a309214eaa5d12c6ade1d5afecf98cdb590d5d67ad79523ab29343643f9d6fe45afb34db61d0d7575f3fa21eac819d3663c5c868b32c0b5fee74ca11dc907de348029cc4f8b9db1008defc55f5f2f7f161d8249f5a5c4e7b643526f176d901a50fd3501be7ca3cbab1bfafd3e532d3cff08a4e43615ccfe9b5c75d661abb778188b62340f9a2f91c7b4e8f921f94fd023695364ce23a1a128cf630a36e69460c732cf514bb3a6512b23878d36505dae42b2680fb5bd293883938fc4964ce807d00a3d5b5bd93eb5328ba05c4ece7a62a6ce579ea0301c8cb04f359d93a68f4752de9641463fa9ae07d1b8ea2c21015539f5687be2977116e4ee99b1230ced94c52486e6ae38badebf88859df164e18ea343305d7153ebf5c6bb8fbbebf3c47cd23411961558edf12b57bf180819412bcc84fbc999fea2535efb01563c48313f12f3f42d3757c5da59e90948878b64f868be2604f8bccc4d103868ad3c9c346049a2c66c590067b890993f7de9b8b229cbe55b7d9c0d3716bb51c53188175fc7bc04bf4b744774ad7dce79d5bd21e4a4c294f8201c1c081602fd3501a925334ef2e47c0890a6a542f8321eef345b2cfd931a0c48c0296b20c1a22f741c3d7a133756ca24ca1455567fb99b6b6da19593a4dcdab7304b5963850e3b79442602217a64245cac37b1aea73afe494057b545324279d70041fe2977232b8a04ec926664ea4c10feb022da5e3ce3ec5a8725192c3d795a614dc479aa0c099f19d13bc97a30cf1ddb36182834deeb42e89b65a6b76cd00b934bd4bacbc9d7aeb0f544059f612d1c8837ebcfc2491fc5e9f1ae8a4b9f08d9877801b8f18c28da4bbcbbaeb8362fb18f6bec531557cdc5231f6ebd4fc73f97eaaeea338c62796b05e0b84b12c8c8de7b0444edd0420c2e5dfe1e6fc5a0c93b7e0ab7f005ae536e9b30a93679b9c5425aced70c1d60ac61d47705744e88b90697694a6b6f32a5eee6b60c4f96d0cfedb03ad96b8172aae6441e01c100a491037d637954ace3da0f416b9364be62df441262e33883df3ba56e9b6f665dbda14a45434e22edc692e0ef977f3d1f902084a3342833ac2ce396859131b64f0cd73bb1be3c22c99fc91dc3ffe07862cae7a34c4384d68d4f729b1b174d55b13e03dfa1fab5af8081d61291da97fd2a00762ae441ee631e242852bc20f5ed8b62a6e4725d977c66b16ebf4daa6511f7070e31b4446339c44d0a90dca22fb29085f2e02884fdd40110ab9262959ff2a85438df9126d869e3d4f7b85044344d4067c7af01979ffcb5598ff17cac8d6b588d9f82d87b8f144bd16149d9277ef00a79fa4d80ea97e7f7e7143246addf1e15e576789c0ad716c44f244d46a02110d413d456f8eb53da3d36589cf777172c14c5d3d56cb7d61471c0a6b22a6dd9f5928fa018ef0577c8dfd5cc5509da86e2a62cab87b5e757e0fbfde1cdf19edccc2d78636ae3ebacf75dbb1121c52ed86dda072db87ddfdabbcaf9b39fdf1fdc072af586e1a091fe00befb4572fac4c8fb4f9ff5f85c13f66f238f4f287c2e8e852729a1aab11188a942d8db8bb8e6483062c8e75166584e8ae11b6685026f8145951f6ac8ca9df676ce965c2f226e5d6c2cb482fd067f50030495d5826cf24d36516ca9894ad2303eda071956582eb6a60e6dbee56d472ec998b3dd3c5d08cf73ced73a7750c2936e23836f36e68544a3b7e02fc576de20e0a76fdb1c13fa6f4090bf91ace61373ccd5e573ee262daed75739f435121df7778313542421441c131cee9cc671fad72b2d1bd5748e6aed813e80f75ed6497522f75f1351ca859a922d1c122fcbd532c82d2a4853a1fb2ec698113421b5d6fc9dd429408c90051f8fab28f03cd7a86c61aefb1b1a833676a33df8ec52b3f697189db992758dfd580115f27596d43332bb625f4cfd5bd5e5545238aa31cc9b706d921f4d8b9184573b9249e3aa6d1d182d86c9a6de8f9b26b71d76d67cdd3638f2c48ade2b47dd60a95d119992c232a14ef05e053601c2a178647da59ad43eb5a4be732e1b8792d8a1d7d9259629ad7f882120b8f4f6984ab464183796bf5980d05bf32d85f61421ca4ff3dfd9c94c5dd3b1b33a0e3b113ab1dda8b2e6fe0daf32f72164a940c9dbbd9db8d460ea919e3f8338257f77ef3e884eb3254b5f60a92e0913d741acf9c173e92e3c0da33af70020649c004845c03018531c5394b3a53668b81eb539981c310270a3c7c4ec25567955eba73d9c37af67abab999f2bce0e14e19e835bda0cc7f5c58851fc4079f704ff8575d44e161f954e835e39ad1c5f9e2a414f890fbbdbfd1a50a1c73fd72ac36e4c2668ffbec8311c76a94340edca158d1acc2c0ea90042149a5b5d198081833bc3f1309fbb7cdf34de6e5dea2b04452f18f8714095ea9c9ab37aa003337a5c5c44a315d77ac8f7e35983106ac5ccee6c21534b87fcc7969e25caf720a6eb4b63cce609aaeec0dc0592340efb93ab426320bc035cfd5901f2ddb66c64b1198d80e619cc73ce127e86ddc9df078d3c71671333c7dad2f0089c65e83070efb0161a3014706337436131cc54e43f0e3484bf24661897bdfc34e64af6d49328f763c164c39e9041cdd3ddf43b1178869d9e4cdebd8e1592acd581a5402f3482c6ae63b34246592a35e9e220055f93c06f704b6484fb7f1b2eb0cc5e587cfa4d4dee683c3d412f4593873ba2191a218d5aadad29d7bea522307be7979158ab102f3e04329846f02793b775c271e7ab66c1d8582e53a2496a438188fde722c48e7f6bb6e91000b05c1553407622bfa2a9fb146dc169b163130baf7802ecbdf0bd059f32bd1a4549fefc9a3a03a99449c9cdbbd45206244fbd9792a69036e8eea32d82ac89694b65887a48308314c0efbf408c689d119ad46ed237c74c322407cc8d499c49bc454dd090802ffc33eff180ca0b3968b39e0df7f8b259cbe95b754ada17686e1530b0a702bca93b1ca42529d68000fd58013c59ca9ff207c4a2d57122e6c374b0c8125176b534bff226a91d7bbea935a07f8602c06eea81ed5ed388524c7a3fbe0dd4c850687652dae368a48bc8ce91711ced188b7da9a7ef1e7d8b96145b39faf8b2e95376cbd173bdeda632b792296dff0df80d4cb3e30fba1960cffb3492159938e0b61a632966284666f50223e3cd14bfb4cc1e95a707677d0ec770751860411b7fe90f4e2c078c11298ba2010c7410594b9de7e6fbe80aea2cb76f8be0c0572defb9d58cceb06dc1c84e197f867452e6a502bb7e0c18d5b1ec9004315563750ccefca4fb65aa1a51aa32773d6519281b7bf6ba826be6f5403b549c3e3646ddff159376c534fcc1e7e339af2ade2e992949d6f2d6362e1c26c70e60ae9669a3a73702afe1c06684794e75966612e9d99cbc7db18acb4a3f37baa1ede7bc419cf655499dac0d126ac3ba833e4aa4822c7bf2c49ed8d94b28055168f4ac738c042b6f21b4dd779539fdd4013688d933c2502cdaed2b4360fcef5c8173ef2c1f5a91604850ec2c81e706d1a2b0c87154380186b812304dcaa7363afe5cb6a52ed235690d746f1a070445fe4ab9a18df19f0d1e87b1a2e9bff724f6c77e2cbaac74a7694366f16620cf4a1d73e3fac311750c406c3fe6c5df4fa5d996d92673571550d694b47b69383e6251171010e3ac21f01f12fe2c764374a3457f34e83ec0c9e87f182f84bf72f1595714c8825a720545a865f223cc3863cb5631c8224bbbf3e082b2c07da33a0b180acb89db94127dbe3c060ef10a8b32298c153aafb1870464eba5414846330f5f274bb6b87e4a2613549853578b7024a249351fc54079737859c559ee066d6186ef6a06a94c19318ae8fd119998b8b8fba2990970a73ace570ae0dfd6a4976c7e240bc1224a410289793d0a97a71b6c60143b2f0163c69cdae4c7dacc707eec9d2de6820b47a6a900aec39f0157e729eece517ce5d1079f88811c6bd1647d32b1375eadd5bcd5b8ef6e9e05b79f4e9fb2497c2d0b1e886ef68b298af6421a7b527357a3cf8a10963d5503a0ed1355ad8e003abd987fb9fe9e26d919ffece2fd1f00fc87188e2a1fd0cfd122c58fab58ba37a61312c68f641908df7043b1b65fe52707eedce969a8a8dd245eb4694e9d01673b1e441d81609b0a91c4ae4f779c7b1838386632fcb1f1dc90d74a3920741c4c0c3ed4ca4b61a0b12195bc5e16f7ea637a38e63f52d0aeb3e4865d1650a2cebe2c14c5a4c2a155975755d0cdd2e65f9ea0dcbde187cad3a88544e0d9b4a4900a590d5a44ab0121ae1f4ac2eb65b5eda140899d5fa527deb95ca4176769f96a68ad3c506723860b0146eaa4360b738ceaf67292a88f4c15f5c91183fab11fa57427a87ccfb1b4214b44c0d2c6d9668e4abe6e4c43934934eb5c621d5b097508411896b343eab7a5acab87607386f907608f6bd3de45fa08183e01037f339cb3905fa8bdd791b8e7d9ee54fc2e424a1537f63e48ad2420d219b14c7025e7d32c0292867d30c023d3900e6aad9c768826c86467b1ebc2ef86774427eb433785f7b5d05db05b056195824d3e40bc2785e40250206fb1680814835100fe5a77ba4cc5816a80b1edd12ee960fc9fc898cc6051d625206d1663c4aad291b5a8b6f9aab95a0e60e9f12f3693f46958ef0fc5ec460d4a5121469a59ebc1b20742c238592976434be70e9406aa2900d31d637dc65fd2de61a80021c54f7dcf90aba4912a73a20038a951127348621ff65add2a75feea07162e63b10021ae0dc0278bcbb2968e8f6f2fa99216a614adcd38433b32b5481ff35082e6f19f002060b1d489bb9b3ee9f5670890d8bf329bdb906955ca9c9b1e23190c4af9b9251320f59505121fcf1a53150766b2b65e55e2b36cc7fc61da94746b17a9b7f97df86e2076dcbe98ccffeac440de898fafa058b7501b07691431b6d32ada652102d55b2820974a8dbc563de8510d65da16fdf79575b59fd2a490177a7f5bd63ff03d48a554201a31ebd30e8c013223a76725afe3d50caa5e1025925a4c03d19dffb17f5d175320e1bf7f439ea8079322a86024e1253cd71604d458c67e09929fe89394402d165020be0d25d6e004b1f86d249a8b4b9e06b5619d165c2057aec4c4bee1a0ee4eb240217beb32c9e29f2dee1bab88aa620d7ed7a7dae80d04f03c1c17ca78e1a9c803b70020b7b27036b274dd398eacccf27a1f8d67fdb3bba2819c5ef0aa94b7c3995464a220487edd3892385c68e0765cf86ac7379a6ba506c3d687615dfd1664a61e0df10620f6e44766be42266c3202569865c8341a8b4a9445769ba336cfacd7b8141f9a9a21f1e28f7a220f0caf78a9ce7a4524d87fb1a8cdccfe6dec364d94ebbba6dd93b2002115b207a64913e0303ec3915a67279f85002410dc25184f06a03b9177f3134695002022c96e73a8fbe87b7755f8f2181f91b5d5348bc861fd6ab35ee71b4ddc5d8a1f210837a3775e5e598150999a4706ec22526e8321f73f7e78d0693595aead84128900219538d13c754a2ac0f1ebbc737d7bd3a4468b7e91636f10bbd980d8253ba5f3a70021182188329afd23ba2916e46880a016b493538ee3ffc4438488fcf6a36d78e48900205add8ddabdab1dbbefb5c2439ff789e158197076ab6b8d99ab37ec4d23e0151f20c876fb7a9976e7b0e8b4fa2d40a26a1f88b5203a992c71f86c863f64409a6c9420ba46a5a64b38094cce0e477fcf526a371f81d98758305173ff85e5af9e9d713520a29a3995c535d10d2de254ce8dc6fdb52a0e6965d5faeec07548aa6b43a91159217f1341316ff39e8dfaffd537063c130f3dd19770d2b911eb407f1c05b42e398e0020d2b3667ad2def5e59fc37b22e196fbda8d2c41b886be1f3cbef4ba78e7fb1b18201d0e660c8294d3550ea90d2e976f0263209275ba6e277ccbec9daba6d361286c2028c77b1955f5cefdec1e35cc2e9121d07651200e90184d7cf32f40dc73432c41217769836ae0d553d95a53b045352d122ac2c489cfb66a172346a3de53801ca99e002094543d995a9f86fb5f49c78fa23d0868faeb3bcca002fd7604fbf81f38c44a712137ffe7281b281b17aa5419276642b8e69eb1b1eabe30ebbefebb022c21f268a300208092e548789ae3e160dbbcc8ad981f80804d9e485003a6c688fdecaeb277b500202d5b57d5d18194fea324bb7c742151f84f9fa7fdb69fac77ed936a56c80cdb5520015264325a4159703b2d38af540c0e680ae700f3b9bf3c069a80696bd322a95521ac7f435a2907331d8dc15dd9dc945807e3ee5ab5295bd574483300431612edbc00209836c6b63d43ee695f135717c85358663d39944bab412134cfd66db5762c9a442145dbfb1f0d944e7f7b6d4b3648659b3b12a4a2c53bd72f9f65e7198957db9f8a0021f8fa17536fd1f70702678ded21a1c7035ca8f088961c04af7c7a4a5df96f0ea60020b7b386e088b3bbb85f1840ff606079ac9ea7f9d0beb62f5c7c5a924913df2c4a20a977ef35ba6c8f89af4b16d5903a1f0d005982c2826797c6fddd0cd2bca1b94c205c0b1932340551606bc9e2602bbfaf633de59ad8fcfe19c4050dac8c664937312028b3e3013ab25c7815169231b9b724e8ae2ca3bdb5fd17487d1fa39046cb77482053b98d7674de0cbba37c37751a7adfbc9c0cbf1b40752921a7d91b08e584fe35205372487e10cc1f1e2d524bb76bc4422d97602f7893c62d28ddae4fc9a896d0372067c5cd6065fa02b76a852744f9cf0b97d32a14ae4cafc94a52087f726693e13921963c3293684a500a48267f5579e77eda8f877d15e4911936d0f8e74b4d38d98700208be980fa8c412eedc13df4b6231e3d2b564296825f490db1e2eac607a355113720397b07219a89c803defc3fc3ac5fc258c8b54b39f53184ee13242feb50a0c62420223706f83565ebce2acd2c18f4cfa79edaf67508da1d2472bfe325e5f20cde47213dee7fcac92a23e8c1c1325e6f086d1c8cd27e47535899399c6e1e4f8784f0bb002014a117fbf97976c0c7af3a56308a4dd19abf6f6a7afb4238e5cd2b41ff3d8b5321bd2e9d0964bab0a1e554eb0a1b350928f2810c4fcdab5ab4e875005cb4a9e69700200e9fee09a4bce859bb38e62a7c74941cb0376d118f1738f06b8a517fb618ec7a20f90381d08f1fa4eca24bccd2e979d0f28710375da371378f74f991439ae08132200b7166584e050832e699ec020e5ae55f07fe8ae4ba7c2c399ef302fb1abf064320eaf6573fcce33c66ea0aab58eef64a3efc1f637b738ee51a95b162eaa9cc476a20e6540cd1e230afdb93aebba474c269c423facf47f2bd500e08961f7c0a4af55320a8fe890159adee60472ed604e73b725c36b2e0a1dc9dc94138a95ab43b38152920d80414b480db1b23a83530d76b6ba4768b612856f328c5d1f481c392bd69f670205cbf3bf512e6647b24098affecb63045ba48ee161913cbea137d89f8c2317e18213ca2d715f1dbf2f7d1cd1843584cee3c6cb663830c2566d2375a8b7d4306a7b3002067451e9fa32a3f67f8940b3d5ed7356e532ab64588a30bc64e68bf0f1754eb6921aab661ffb9e2489a080b5dadf8b66a01b4da585f1d60fb19803d7870aab59f94002009a42d8c17bc201a7683473c104361db25afd272558b431c7205c1ad60e5275720b5259493fe51d34e9e9f13cd027324de99208f62fc7088503d065bbd22eb671e20c2a1ce148baeb48bc4074806162c5081bbc4636a01d2947e2e511a8e23f05010217f78c01cf3b1de88c2b5efe4f1a44da7b7ad1d70de3a9ee75de52c21f5d9dda10021b1e53880cf898cc3304af3330d0dc20424ccefb35751124b925e132d89ffbb840021ddada2ffe00b2b281c447b8d03562bdfaad7248bfd3b82ac74178258c17f629700209f39211210bbb04910304087e2907c3a8a12ed4142aaa866b6916b3f17d1c3232113567eae2f96882409da14e61072dc3941a7592b816b25b3d52f3ccf8ba5499500217e8262ee95708b1b40ea9644b6307ff3886fa0159a3e28d6155e3c4f737e2a9000208a5f96711ed5da026ea1c3e40ec96a8c5860e871ca599c9ea740e3ceaab2480720cb25ebb06c94ad22dc7d529f3296366d4f65781e165de8cab751d4fe464da97220269dcf06c230675b405eec3bd7b1e99d9191242bb0d8f089d31f5d41d61e4768214635add2924737b775e5c252b8ec10a1d072ac4ab941d8745ace8db5ca7c72a4002025a5b0916a1b7e739c6926915cbdba2f4fa3b8b2728a7030cca4946362e3e84d20c5b5d7283e047fd80f2281445463424ac2a6f1aaf2053bbc3c136254bbade21850801b49c2a7eeee02072a84d52810a6e308b5b895f082b83827d566722f46f9dcadcc7437e6a5df1f12cbb56bf34473a0bbd93b18b130a8a3b98a08ff3212094ecf6309aab5bb96fc39e51df0828b70ed423b3ea325d175f412bea1f96c89ae4459987ad12891d24e968ddfa4f4c00e2fd4ee2d08d2c0e6ad48129c32fa7bc99c3681e3f7996a1b93387a10520949c62c2c64a0ec1889c5eb5c1313291a78dd7213244c21eb9a9da1b77c9ea77880305bccd24ffbbad2883c52dc411485b64a291bcc1440f9eba8277d0d8db1ebc00f874f52b126e99fa1d1ea5174c2556085a46a0223466cdbc23a9e217afdd1de8a60be75e11faeda6091a37299745789b6ade6800081d69088cb8d7bb502ead5f1955391de9d7fdb577fb2da28195a81f6902612316ac9f15ae160b2977310cee6660ecdac2fe9f801f9188635c83ae12a89e3aaab5ac05d3b988fb6854f17faa24d0dd9d29d79489ce3d453903f951a6c83bd4c5874482dc6b0e4883ffa65e4a955c45f7fe7ef32f5ec034595c8216cbc62393ea19900818b43280d3245ce70caa22225803eb986dc3353c37d798f84761ef12a56e00ac6dcfb4350a8e6f108b0f10a1975d0e47508730903e94a2ee8d9f36561d1fd2802bcc103367e15e325eec1cb09c86f40d632e9bbde8b2f6006b4981fed1772729c17d1cf3859e4cdefe9246ff6f6285450b520180f04665c25527cfc85da4596bf00804399c22b05bd36cc68e8e7b5c2625bc34806eed211d86887cd37742f1108acf1f06278eb9028eee4673e0cadd2a5e1f5f257422afb0fcc199e65728ccd12fe689ba03b50dde3957bb674b01baad178efa863bcd10de5235f3fbac3062933488e9b4a60b2cb716c5c2a9648aeba59eb3e50ae3be842336355c36231630a918900fd0001c22157003bf3613cb4e60bc0842ef72d03c3927ace3e35f79e7975e6d93593c1727ece0f9734776e3fd8354869dd0c2e36d992e493524f97875a5798e45ad8800d288ff3c5ed1c656298547b3f386690d20d323daa40d684b557ffdc2fd64c2f3f71938ffad426211d4e0fa1ab71bf2eab2095a61868ad51bc622506f95d2186870b9fd55fadcab4734a96bb996948339408559f1ab3d0793b6ff3830c22dcf8387590bfee93005b5baf5890bf9e3c925d40906e714205aeddb42376eda4f4ac7d96bf9a74546ca377bece79b690d870a560c3b1c4416b06bcfba6904392ba19214fe91184b7545019fb8a5c65e0a6919720dd962c91f98992177eeaec4665b6fd000152c7953810a6139a35d9ab44951eacb6d7f88b6a2d0fd1a05cf109d8f9b8092d1e970d6ef12cbfd2f8f901baae01d8830b8cd521e63300bbc1bc623fa5c0e48017333a631d42b0e71d1508e7b8dcc53fb304d4480e2a4e440c9e53204482c72d97b4d8561306d64030846c9027bf218567d607c4a2304df183036f1861fed60942ba64961824b80fd8a828499888f80a11cf91ad2fe187aae73605bff8a4b004a2738d56a5abf11f9b82f8ddd501443545bb4aeb49fe39b64c7a768380892c6f00f8cfb49f4594e1c88ceec1125a3b70e890150dace647307c1cfe715642756d5d2f6c28218274ca5668a3c2a4af4b79e70af8b83de56337b841c94dcef0c89ffd000109c0d936c59e7b389dd374956c37ab4b8978cf0aa5b7050dc50510f381eabac6fa2e91934b72e798eae1f6f43be168ce1ef600e7b9bd1bbc2c2c963cc1c777c41ea9ce7cb85dbb140bb01cd90bef6298783d8c6c056955eb83b7b9df63ba4b9cb4201cfc83897e54e269398be9aea1e293fe7131f92d22b1fe8ecaa22cd934980f4f0e1b8a91dbfbec640010d91623780a2e7647391eadd5a10bedc3efbbdbf189c33057605f1cfee70d8ced664531535abace6d63bcbcd12774d461c91e4c836a9534b35a735f211cfa324d74febc41fc9ea3e6e953ef555deb6ad348e35ef3be21e32d2546006d43765fb7275d10c47618d109fe806a4f94fc67940ee02aabfd00017585f2e215c1912e88aad895e87882da714c625143b5f1a9ecb9764ef1e1a1e654c08c70a2208e371f9c4b2aca734bca273072eb9cf5621c73ed442efc85624c10b0564c96f488cd5ed697fb7ea414c8f49f5668c4b41227cd57df071e004675cfe16914c9e3e018ac7e0b720b9cb9496f2d0e176ab2d611ede6e80ef5803566bf698e09b80a81c0eebe58ecda39093f0c1651fff5aff860c4b2e70460bb95da3a74cae7e26139d1b257ed9aae65dd4d86e240f07ea77f1691be722bf9855ffa759afac8a1e6c91326da71a1120092a914507c2000a167966a74c8e5fa8533078be90087d59fa75405168d72126667458525b6406849bc1bc9a97db49a37d084fd000154e8d56136b6dab9d5fadb3065668dab000eda7d2a8c47b342ee5c95281fa8e2fcebcad5a0943f2ddbc46390eac974b4b27ab9da4fb3747917c22305d3ad91b5694b312dfeb392b55df60cc8d4f6950bfbf4d5dbccee860d9997d2de34bba2335733909110bb273c2e36c15315fb79a93d1bffe33c358e2da4c238e8ae734fda09936a758f0713f720bc556381e41f76c29b7a02bd44926d5b2a7d818c788315c253a90a03b9194fbd581603e03a34bb298d8a6f4021b887ce813f3cccc17a2a7f6bdc5b50a681723890250c4e9050694c9a66fc587187973f209c1962bbc5bc7ff64fed7d6a171981b814a80a1cf3123a8dc622008cfac1baebce0dfbe52ab080209b50f0445fcf9488fe4818e96d8556331f20c211c60e07f1f80e3ab23103281a07c5df8d85c6fa1767aa997ab2dc3bbbf1533ffa8729bc02f6ed3d9ec12441576ea311a1e30af774c92f70f5d4521b1a67d0b7c1571c45e23785e70bcbbae1da98f8e2eeccbc67ec771a30b68e37f8a385820bfdfc7af405bd5375df20557cfd000167f18545407c8f34edfe760a91ca58479b4caaa3964af57568e4fe511cdc94e99919ac76e43c423dd4024457896c2367cb62da0ebe7d8b98cf79981256d870421ef6adafa61bdd61a9fa752a3102bdbe90ec1f9ea1402c855c2a78c5c09ee8a4297dd815aba0b346eb3be92a04301c33c83b0d02ea26a4eebbfba0b71667354bd8e6c825eda303b05207062b3b909397026f469a3dba5dbb851bd28500322b2b898efba194e9c89a97e378691c6c3587f7fe4bf1a3c69d31fb9195ea9ad626406f33bb39e8083452035038c1714d2753ffd79f62643057bff804d7693e014a80a9e32d1db6e9219e55ae5d59ca7f9615b252132a559ae8f0ea9bb70947170fd3806fe1ed3b59b8df259900cb793dd2f745658cb2cae325e988ae4259bf3674b40d952737b874531487ad58a38fd6d01435d4e14a87b0fef4ba40a2c985cc62ea2d3b6c97453c8bfc61ded2026a403939615b94db3ed5388adf92480ebe647d9c209541b9ab97a67f8afa8ba2ddc4f6621eac975806f7a2935a4754ca1281407254fd0001ca584567228a05aff314a6bb8db5d77c64cdc98049e4fc4e8a0dd02e94d65e494a83fd0573bf26071fdfce8455a8586bedcc9ec3912fb93c28c97c76fd2bd8cf7c77eb032f1b3d5f18cacb4d6e46d1d636e5423de333171621ac4ddf00cd140c8a31cfe6e1720b702f5977426ba0f341c5c121fa41e5f9cf72c676d7d8840760047baeef41a85ee0f58650fffa0dfcf4a354b4fd635f65d533afdf68682c062fa1ef3ed0345e0e6a4a03b2dd3fb6c1918fd4c6ea2e88efc1223bf72d33a12ec9f10212abd8e0d323fefe127edc909daf018a59e7be84b92ad9506be6cc080fdaccba9d0e6153e49ebe546afa2c3a5fd37294b035eaeb5a46ffb1020a5fe683b0810df474b799476566c1a4287bbd112cf2fcedd1be2cdb8707c55db9af086106f66a061f2f39e2ea3bfd1cbc18dcc049d9011336b2bcc240f731b3f45955e15d228656e7d41424ed16096607d48dfe0e2456d877645b5ea8f006b797958aad495cf7d57408484038b87ec99653b7fdc5b8a1ebf47b7883218bb9cd52eb8a22e49300fd0001310d818741b56832a1a31e3aecde85d578e6bef95e0d3321278f243dcf81ec7e2e6780e1bd4de223b5835f57184ea2c2edab2b870fb13f620b3124f2fc83740c26aa30b917680eb4a61a3d1e455928a6325ca2c330a74f35c659dab9219fc1ad2dc4fac28a8055bbc1acf272e294b21d1c3083c105b107e9ddd14314926a5067dfad3ea37c54ed50a5ac96391dea5fb553fa689d4166b8547b0af7764d22b31deceb9d8b25bd2edda13de0b952e8c062504896af885bd026edb9708bdb23617f0fc68a726432ea1c929262c82bb2be5f1536c6f88d33b308f8c929560caaf74b8fe5f840706e3e0b81bee0e46cdb134867bf8b11655fa204759bdc88d492eeb18093dce3035bac48a26fee7f6f5ac66adf63876ef22300572f528d5f482480e951befca94ed142ce71d311a3000d7895f2d9f688edd34ca44a68a09cfc4b685ef9f5e8a6d75e956e99a6bd01bdc002c94cf87861518df3a5a0713d7ac072254ac1d68de90d6a521348969bc2d59fbbcdef6918c045701421c92ef733b3b7c4a3678026ef6ecbb093dd60690ab9b7d28280a81598793788de0272eb52423c3b5335c844fae3a69374757a3b41e3cb2250e36d5e185eb2e67950782ecc1d31398965fe54f680c52b1806bd764fa2926377fa6f7f909ade7774b8a91a65049bb6862048d389c3536be88e1800ce95c0ef3477fcb5317ac511b78dde2dee12fe305773188132574bb60a5e68118e2373411b35dd42ca882b7b833c4efc20f1f3bab6cc6ff7036d48b2051bae2ac95dda94cc330ec1d0e3e09f856c7e36c44020b01a5076268aa5ac517cb4c9e936f958b7ac6f8fd67e961e083487b2befc7f923c559f6c52309b677fb090a604a6e9454c2461b2fa1574403fc2438fdaa1318c606707c0c600fd000124940c5f2606ccd649a9988afb6775e60891f95b91773924d7017af430cafcbd0484929b049c7a8372852bb695ce1748bdfbc150a5ca6a1519c06e5982c990e6b22f509a606337a647d9d1643522264b5838390e716cc8bd4f47bb8a0de23577b998855752c434efe432595f63529bda7c7164b321304afaa6a4adb71dc05c25f5e5294b69b21c75a13edec9f8c0a31243aa73ce6592f1bbc84c4705daef99acba57280dc92de02e17f1b28473f200b3e4a8e577312e51f1f79c06ea49f9f1a27eef83ed0749d5eb6534f9d8ce773e94f21407cd17154c644d8099b4edbeebf4401601d3e3667c32186ae79c69abb3c72c0e8220b2ab9304d1307a686c9db992808823b2b219c9f81d5a641e40be3eb71e841db1e43d571d3b225b5d811e9a0101b37891b8a962be19c7b127961ac447a847de4782680d3ced69df0c4f032ddf36d9f7da47aebba193b703598c12c2214dd41953a8fd4c2956d261c989d560d09809e6471d71c5ccefb171e1b84b806e1ebb792b40fba818c40a8ccbd07ccd5301fd00011813eadc77aec30750c84dd27a1dd089ec245bb82d93aed9f343f7cacd9cd49a22a4b516df334c6981cf57d9038700ef0eb610a70bc71dfb1f4d74ae3359835b67090bcf46549a2f8eb5e9d9573d6f2900efa6164528cb2298d488b7ba8df39748f6fe41ae04028fe3e171c68cf7954b228e0e54f266f447d9a93ae944517fbc95d02e898ee7f3619a02abed25e78cdfdfd3ee09521a5a1067790117d5641cde06554e7aff909ad7f7d8f67dbf9fe0ebd1f75856dd0face6d53b10230d2f605b1c1b022376c2f569d9849bf094f7b47e5c1aa5f88d3cba904de9fc2299ce60672c59b6b951ec15809a78e2beb4b64db2768a44d253da8268ba4d6b1517b03ba980ea5c2231b957018bfcb1dcecf26ef89a5338976732dbdb6f7354da85b62d1f0064a9c99ffbc36228487ecd45f4d605bb3a2f0113b756f61bd2d5bd7a75019489fb9b4807f90c78004233c53031f7013ff3fbfe9f37cbd657c61e071dc1e48a5c15f5b1cde2ceae555497228b19d2be443ef59e89067504c76df6197e899aa833fd00016741248c9db871fe238a8fd2a153a21d659c82caa6d0d5597a900979d10862ef7bbb9643b8423a704cdfc787bad8b06f693e0279e399125db92681391a88cd1f8a3b9d620fa3d29071d4b540fdda7d24886beff41e9624955bef1eebb27505487c6b650f941c5487db2d6a9de360fac13754bf6bdcacf8f5162e78e1808c2021468b402d2de932a590a22371ae513e4f8385cc3d54d6d8112d30b5053dee2767bd5d68b1cef07c5dbe79be7a13b4dc761b303625a35ffd50cd1a7a7607c34f76b737cad0a77c991efcc0f48ec0baea05350643839073c4d912d7f6d18ef80f1c42320c5a1949abf9500e5e027f84dd326ddc25b796e885f878be522c987a47a1fd0001892880727994f3ef4cc7bc3b009d3ab0ba12e6fbec400d978a7b094fcbd63826e5a2dbbdddb37042f9d488f82e0f6d64bcf88d327aea5615c6445c13424544d45e12007f26b62408c19eba388bcb27a32b549f048cdf1a9df32817a926ab34e130848792f71cb81f0dc000f5b640972a5d1180b3876e2c170e3ef31e27610e5db6cf50077970504de9b6354284bf12106151876524752dc34024d7c353c8618c1a0f54b958edeb421d5d521470bc1edabdd5106b7f89c1a5d52b36c7491d76d6d52c153e17e692a1ad389a57564aaa352fabff8b65dd9b18b6e76feaece6bd76f09a88d60cc344d1865aa7b97dcd9b7ee5d869943a0aca6189289cbdfd9464cdfd0001ba441f650b1c2d89d985d28731ee44502b189fa4ea4e283e9ccc8f5aa2026a3b761d9c83d2823ac20f780b887d2487f900c5f568f2059f44c2014b08d7886be7d6654dd8c760d82a2f4bc80e06760211321638b0c676bfd4254fccb74b497e866d33885345ef0a990aaf150fc2d36ec3a7a2b21b3e10356b6d7ee5984273f34f295ad3c9ba5ec4af588da45a4b512587181a89d3cf11cccbecaa9a590396b63f27ac3e157a08df9aa19867a7729910a02ef994441cb0a733d957c5e41351f8784776b45246159733c6818c817f4b7f219a68c13dd02b0410b037135025fd5a07f320f44a21926c5c243636bbc3a0f437294bc019a8bc8e9e14795ea712ded2d28092f38d55599ff2c0dd2650de43a589a498fba4d1920cf9557aeef01575efb7139b8cf10b6ea5ab3ef9b40d4a90977ab89c55a5af3fbf0f8a72197abc38dc6d6df406cc7531260a8d5e36d3ec1ddb95486596b45977c1559892fe96ee1ec87d54083c8e88fb75590898be9bb956ad1593009b68285fcd60e29a392130fcdecf3680c4f08fc6aed56784e471ad4dd0d0146e0fd41c4e17d1e660daa6fd01634cc52a48fc71402242ad1b9a1a42f254b433769f44f895ec40119b40a9f731e07d5b5a08b65c2ab225a933a92889e4da908332a35de27bcf88db00ae7f7d4fc3270b4fbe1cddd3934864c12b77d6f109704e9c0835742abcc29dde4bcead8b0c0a1a08fd00019cd781470ffc9915bbffff9b297207280aedf02ae6ce1972e2ee4f5959aa022fe22098b388eb542ca03a1af83a0f526eeafc95b192c2695eb74d1e55f6c61a950402deadb11bab08257124b0ee26ee87e87570aa7c615eed73c12862708012e2ad8775444fda2687455a0c2e79d9c87e2c8b6eabcc3622c1bdf14f94747086ef33aeafb3b282b1cfbfe7e20bd20e57a00cce137c764a07a8f25e96cadf0ac678eb79938cc592b38b299d77d3d2dccf1bc3ad3da77d15bade71085176833fccb4f4d813b43fedfea06496c732f0ff2898fb0a13ccd272a51da2c7e2c92c0b47106deb290f12f1927efd87500483efc37b35bcad9aaac18b7676d4356e9080cead811cd4046723cc636a017890c78502f131ed1c4f2bb1c67f5a3095a1f39362b5b1d769e202127eefb4c36b280265946cb8519af11524abbd4d0d35b83d9516983b7053f3c5a583f7616ffdb271612030cb06448ce5aaa264ffa9dab04c64cb246fb188fd20ab61d2e39695d47564fb8485e003a517b2f6267c9147da7051ed4ec6008140c144235a6b9076e23b97a81e347c8a367d9c12e0d775a378337eb3b78647fee80b99107d47307ae73c15dd22a4210f98a5e4b7ff6ca8ea79286ee5acded439f8633ce730ee68947fb12a323854ae232ce75fdfa99d274926b14f81e93279f5ef42cf7abaf5e4db9dab5e1ae0442e9a4816d9df5fd1c671ffc2ff9886c50ca400807db6e16ac5cb6fabb094429a97f7ae57639537b30b12f634196798cde9c00a85fef093cd983ad3f4d9fa8cc2168a8331f07fbac52c2544defd008d5d31905a6b4f57e2b786c091b019dd4a23167a457f2adef68fdd71a4989921698a451c323faa2870b78f555c3c30a56319393d5ba640ee9b0c43a2daa29c5c080b4dabf229814d08f0c3c7e21b9fa04c76c7d6c3f12509c060015fe82ffff8eaed0caf49974478bd49b94e1f710945a0c233577808d97d435f09f9193b1e7abe8aeacd1f6f142c9eec20d7427cb919262b8a81372af0523fd6c219b427477da7715f7f07d48f890b751206819b8693faf2c8f6b1cd42735ec457051022d063446c58e9e23a940081d24e2fc309cf1390ca944179e6dbcd7cb4e39832f3c8cdec876f8d964cddf3c27f87802eafa33b2ef393e59fdf9028f1add7988eee4140257fd5b420273d9bef73715569b0001ec2cbcc13e70f3be6f0dfae99b504ff2fda3a3bb4974ea056e1fa739eea46d38af1f2cf3ebdf32887e7ecbb570a3633c5e2425f29d24a5ef1cf00fd00016100ce85d6fdca9575406f04fbad2d9cb4d7f6fa1393be3c3d6fbd5eaf7a99a02f42b8c373bdd03f7c76a9de409f943519dabf438788e2d96b34b7743af43a0b01bf8a080615013519a4424a5ebc90cfaf822719f1fe59ae708b726307243bf7cc399be43050e8b9115ddc1cf4c2e0c4b2a6a9674b1b45584d7b4ca779eaac889dd720bc46bfda1ba2af747a8e53c2b3b857e1e5b607ea5fb45ecf8980250056134dcdb481bd915bab49031c6ad7e3faaa58e39952119d8729e317e15e864512f0bbcc6898a71cfa619fd5753a5b32bf98dead20f99284042a0a661297d468445d982d9159c44fa344aa2cde29454c7b08f3ff4671f23a4d062ec8508b67dda681c0fcd0346c255f430aea7066ea78b6571e8493274db67d253968f033f88c42a90f40a8298aa3db289f0e5ec038201892272b636d947f6ae9c6342e5f081db2b188a9d3f50b2e8fb396fe189ec082fa63fefdb33d11dc2771fa1fe04b23438cce2bfedac58a1c6b6819cb7b02fed3d74e8fcfb839df07bc474ccd8ee76cdd35bd0081ea358b44b3840116487e3f85d40ccef06d78c631423996e991df95018dba5db3cd0c639c4e76122db272e4a59cba263a26cf5877481b93714bf9d78b019e7493443c73c5af84cb6e9837b6d809da037a573a6b68cfd8bda5d02da0c50743e889afd72406eccbfb255f957ad00fc76a8c117d182363dc9e914dd3d6cad81e32bf00fd000133a5e5ee9bcd22e54c18d8ac925859144d32339b2bd00c32e8f98b754cdc3495143c425da51b3db805aa3884ea27c2ac815e4f5575491bfff800fb5a3c1fc0120fbc33d53ca941115350be844493da6e3dc40847fab916235bdb3409a356b9528278102b4d96e93c27c88a081bf0bfc4462ae6a2e340832ee0c76aab12f192f58b4fb5fe386cf566a762f83bfbb88d1859a10d08ec78b01536c3dcb69e9a441f9d2e947e898dda40f57fce5f0e5184d93935fbde32cdb750c51d57cf7c2be917fa299a01c41f2a1018d435380632735f9ad2e958cefc8837c21172bfe67a574db3d8223eba4d0327850bd5fff38459d4fb6e00715ba7ee5355605ea0de209ac7fd000112ffd4061db7a6cc8477b6145a93f3e6fba20a368be255f4e435dfa8c746ffeda600b87dc3e5fef1ffb6e917b09319853adea9701d795e244c4937d25e0cb0c62bf4f69168aa82ca022f6a28a7b85eb1e8eebbbaab30a61c785e19f59232d273d936e4ce4b9c62ebb3ee2b96e90a5a4ab633e9b3704fc0f50ecc5b9ad6f3843576aae92928ca7c8d09e3b87a6281328a1482bb642005cec7dd57f3ef9b1a167387511eb339412c109bb94b57c4e46e3f33d6fbbdeee42e60ebc9f44f7530b86a94bd9a9acb974c76782e954e295770716cd216b83036fee452fb2f83ef077d673f1126ed412c8d9df216b0cbc72456ec8e2932de9539cfd562ada45a389a4d8f808cc78aed67e86adc2cabd1bdc0b21969cb52b9b1f1a1d808d67d8e8bd478f293d9b81bdd65949f5ea0bcf48c6fd5995ec992a273f6e335e7e6969d008590a961240af5ae24e4410dbe1c6dbd001f77406731348a0dc4ce2f8a683e4fc7a49c659207ca2bd8fdcb31d39e58a8c75f76031d5b65d96f0f1af90da9306f019332558135064b7f64dbea34cd68c039d4dcb703f63a0a1effa946cd1c9bed59a956cf85b358f4db3118070265f8aad6c540b066f29852e005003666316066b324cb037b9b5fb6ab8b2908b1b5509e9e0ece6345cc571c84f83dd0efc128ff27a1b5dadf0a0921544e9490d13fc82df1939382f1b6170e8ca99b38362228da418dd219970081840ec70b20c096e1be5b162d058c5c6db0b672d4e555effed3ec8ca85dd69afaca09d85cc206d179994b6a0443ceb4e65071ac7a64842c8a6b2eb8f89519dac433829865747586af18ed1d8d864b75baed59daf5d08931cf47dd2c802545505c9ae8d351ae00efb35c5725f8d3cfd87c7792b084096af67c7f63bccced4a038d00fd00013449e73edb7c7fbb2df81482f1d22d02eab854f7e7a1362e9a41a95d0a8ab7bb68816dd19776e0cde728b6cca402b4271b384f71053bf501ec8cf40167b76661d11aca1ddf4c47421be72577dff8be5ca3461dc8cfffcd99fbd7accce7ebfe12ac6c4f8d265b1416464f2b94cb93b40957eab9e01bfaa4e33948fb2de3cb093db74e914c3f048eca8699735e3693752fd59dcf48cc92b63594b8595052ca405ac9191c6ad3cf08c6d92c384a8eb0b643b57e8b9f91ad94ba1c7f5d8aeff0baf1a905ae1d722af5d1d4da2e56694a7857f99c114eb63d6914b0ad466b6ff0970457730cf6ebc607b1064ab3e792833de818ce7f47fd212d98dedc8602d90a08b281fc8f5ebc335769ddebdcc8fdb0507d0d814fe95333b151b6518abc1221bb431aa83abaf371451e4ade47142b1c1159b372fc95380aa1697935bda8ac28ef6bab6c69d6871ed0242087f1e69f4ebc71066bac94040f79e5fba35c0bc9085546634d5b1fd7f5c85577fd7b645845ec87623eefaca134432ab7663dc7f5a66f55a80081cdcb09b132c40a0e33f8f9c47fa993a3d3c78f5b4d8b7c0ccd2e343cfd78819fb9d6556e3ad0acf67c85cf5cdd9335665761a091200ce34bd81172e0bc87efdac66ed1d4d849f9b1e94ed2db44601b8f07d85a173a6ed9a76ead0a21d48421a608e17baea8e9a6b319c0c41fc5917263f6c93208f9fad8ae2b2eea2f702a2fb60080f98b3379369444ffa8fc207955e7b01575c7007ed19ec7291f28b93db7aa7148bffb9f98f7e3ec1e1f21568ad37f91c4603d3f276ff0fa7e9ee8ade05c277775c3ca2440d50d427a23f7aba81b8b1b9bf3ea8fcddcc3c3a7708601688fda8a6f41d0cf7b5aa4a03422f332012ede8fa6a9d8093980f07b87092bc6e48d5ab343fd0001840e370e28d6cfc75778bf5612efae6c33b4dea0c4964e810fec77ef1875956edcd2e22139a485fc4515fe44c57149905efd72b16f4b367735c1e91727632507aeece411a472e4f270e0bde542aeab9961d4fd0898b821a6f193dd42391de664331e33b9244e0598669269c73125b21765f048e0c2b9d17aeb0cb3c112a318040ea4126c43e0f46d1d9c1304d95f35b875cc2fc3964970e3602cc51f2c496f108f904e2dcc8113223a9a074c344b42662c3fa22490db6ac63a1b9abf0dbc97feb0f4447eed2e96f33f854f695ce54e24b9ec180cddc752cbe66fd361d874873ea3733a4ad92870b24efbd22e928deecd7d4293b680843d75127c0eec3f07f894fd0001266e0fc8977335a776a66b44c25dde8ac3f40f2d195dd3845a0019b5062043d1e1f24adaaa3052565db20717cdbd769bc1cbad88c0fc6a205fa4acf472f954d161c3450cfa0c622b3dbdbb2813af60d47870299c1f3d793302c678a2d4cc7705ee51b675dea5955fd7ddf184cad643fea3f1a428b6d49da35ae251744cca95446811aa79d4edbb1399734c3b3d71c7ef455eb73f72ff013938ff65ffe3d8cc8ec321328775db8884cdbf9645cedfb4d86bd54cda89c7a4da2da5dda4d4f459518e058d3614a4a9475426c64a16bb206d2a02decb9e301ede48dd0247d36d5b9fa1e449f44f6a3ea7999f31259bc49ea13b62cddc0f877435901da6f9cde2e4ce816565e8a37e36ddc7eb0d1363f2d0641db79c0da0fc7346cbaada28e859cc7bddea3653151df18260a7609aef925c9de21299857732ca58631eafe768ec58752d63a48b65895e428bb4054b8166e61beb6e8127488941601c0ee1092f695ca0fa9d7b965eaaa4dfdbb9fb2127a75447d02f64bd3c4f4e285e781b02d98b21089000fd000118a3777a8b2c2a8e5e9f7fd85ac71b6e843ae5ac31d3e192b73c830a33eaacd7ed6f9d5aed4754b9b6af55e60dd31ced5d74d2910c2e9a500dd3fd8e282136337919b3e8faf81a96315f04588f7a86786b922c6ada489eb90bbb8b1dcc85e8f6c6d3d5fc561f4ee579e9143fa4726cbf168119fa5e2b1539327327f00ab363eb065aed240f5d120096951933dd3e689118d2262e01e5827c120f53f80dc8c7207f2b633aaa0ebd350e65882d7e9069190163975682eaeb8570c6c297b614a72ab6a6c3276f754a7fec8ef86c50ebec46bf88701ce0c3037db989247de5ee7aa731ca30af5bfe9357e3f0de64160b9ccdbb0eee9885478a6e9aab6902227933c8800ae488d64c37f7e8713e92a1ef4da540c5da96b55f67bcc7e5b3a0950e75c0e75edea568a951e213d8713c034245f6d6e307cde14b2d7160bb2ee6628d3ab486d2a0a9c760478b56003f84f6ec15c6ecc44ceabd1d1007cbb891c005f62fce138af30cc236c0e31e0d93be2f94b9f3a7ecbff058bce5fedd4bd294ef8bbaa0588002c6177026c1525bf82d44c5458f84a9105b3f6eda233d83608b024ab3aa3646a9baa480c983df90f90be078d68a80292b8a609180fa075f93e99590018c49e8eda3098caef604bbb643ea3e4a0ce82407a80a13e5f5c786746571e74883e7548d881a9ad516d0b7ae5018ef44a5a93e227057169302cab4666a35b3b22ac678fd0001df3435a26f04bd17de1cf54277d5e8b52d4bd552f2b27115e6c65a252007b889c3d5178dd15259d2216e0e3fdbe065b38b5f638fbd77ade95f13882aeddcf59d508f0252647a0d705ecada91727ca37541f25aa4061a9ca2e3e3dd2169ac00a508db5661b41cd1b626a5d64e9a093b3509d86c122264b53c5fc95d013c6b8d58a9faf515af46d5d41610ab99555c2c3ab363d604fa147b0f1bc86a3da26ad8a4614e6a27f84f58b02b698c232d6a6d864e49d1fc95aac2fca78e1483c53c6344a731ea31261a19bd5b7190d9ccb8ed161d963d4949d17bdb8edf19d1bbd0fa9311b2d5f2b3febc5e4dd6e3c6f2e169ac81a671aaad0723ff8e6b0228ce57ef8e81423a9b6290dbbabe3ab5e8cba4c4e6453766806e946f6261557c2b23c05e7aace181919a12ac324fb26f709ee0eca8b746eeead2b12c13f01c39d5b117bdcdd39fe102d66bccda50456e8994ef9924e22ce634ae801c9cf7ae24d72fd568379bb6650f77af9dbaeacdee02719fffc07ad660fc01b84dd88f35e6f97ee3c977b40080a08b918b6a42c1e2cbf0231135b5a666a371bce41d2b53ccb47dac374dd8a1b9d0469281570672916c4841692a836200f9d4dc3d69b71fbf6a51ab597c6b11ee6d772fdcf02350817e4d85f79437b5aa21ed0407fe9689128f9166bc391ed499357d91b262930834e96b1fa8505ebd15eaf85ff38db7ff5e9897c1ee9f5f883afd000161acbe21f4e6258c082bcca1879e68a20989c95cd5f7fcd1817b807d6819263f1d32cd7882282db7bc2a94b5ad3ff053198fb7d89b51c0df65493652932b4af07023ac9a84793269798b2850d1296aaacde7fe3eac56b88ea9f6656e4576b58ab9eb13dbffa731c6c4a57c2d06fdae1ca33e1fd07851afcc9d5c6021a1e0b3524c72bac8c9ebce52290043b3596e9bd0639220d164fb41017e08c632fa32c798c61c643af591b15148d585dba6baf61948e53d74a42afe3e45505fa249c6b6429cb40108f107983b50c8acf42c9822527413d866f3605812c3665263b0796e7a0c632464c131963eb1b39eb54b6d117fd25fede83fcfdd5bfa3edcee638196ae80213f7ede12505476e213d56a89224818d5764679824cfb61f0365494e5a4f26901aec701421c83145e75008a4472d66f06ebb3af51b886a2325ec482969d59a86dfadd0073596b1d47545699252a94475324ac07619c4b2664ee6f3b83dfe1c7ef6ece6a73f1040016d887adbe9d8e076de14815f8ad6bbf89d82b11c8de622d8094d122e7cc525f099280b1d3bf5557fae729b8b4287fa3f8a92623ac0cd62ab57a10f3fcab1493385a909c09e80ae7f7f0d8bc7459ee95930455412ddada18aa6bf8f8d98e5b7402605066cb4b6b5ff5cab305771cec6fd000032a289a8722d0a402afc66696798c6106be690d05ada3add2ce5947851c79dbfd323dfeb194718127e1ab87badfedea9cf54d4cccdbabf719b5d4af7f3697343b59974b5e263687aa60953df4a207784484d8867fef51eee561964b4b085ba35e822c22bf33e7fe10480f5a63666a5c654c350390521cc06b63b9b215ab1626ab9bbb8fccfd1076140cc362ef54ac515a2c924781db8d102e9b8b64149ebdd98bb102cbe31305ba0081c572ecc50846a7dd2e812f5965e3b12f571dc933b0bbc7492e8d4a593191e01ff9895d3807afded49d76ebc51e0b2b0072cf3baadea00568925088a1a6b1d5c659fda5a6a9af46329b067f5ef1f80b2b5ac6bce86c909f96801fae3f620b9435f49621a88f47da90ca9d7c8bb3d52a01897b92f78f60c64c9b7d9b2e7a1503a000fd00012328fc8b7f3b07c470ed2a03147afb9dc212ab82e70241794f999e711ecfdc51da0413b06167d6873a91a8335a5b2df0cf067681355bce28f19f5fe2a5c74dbc334d077ceca07c981b94c89f5b6aaa03b70fa4a2691be52d76461356320efb66d1d30a771132b6dba09c35de3ac8d46e9894a44a5e3757f17d2c218962af03aeec834380e2137479343535fd8fce9f21cdc55b3ba54a8830892a1afe865c39804811be558a668764eb6c206306132bbec65d21de4da92d9da947e28bf1fd72dbb246f3d5a32bdb7c2c4c6554c80513858b8b863f66f90df7b84299ab692b4f638beced0e40179b25ca27cadae375e6494298f1af1a9c6c8ca4c27012bd5a578dfd000100a3219c72f3226a3dddbd68ee35facb6b68f03dd8224e56e3f09fb6c1e8a5828471890b83eacb0acb587e1dcada3daa1bcd82e86588a3bc4258cad212f965ab7b7b67f1a201c3fb65fea00edb539524c439bd574a52795c55307dd5c69303e9acbdd82d227a866708e391d39f30a450876879e9e96e1166af40843d022a98b6fed4a3a75768ba478c8c51937b0feb9e714901bfb03625225c8f6079fefef5116d58da16284e292538496390666b292add9794937abb940b69efc3689de9a1d881128212f3da736d62a629ee1b5f0d0b9f14f68619352a190694a31d7cbead5c6e88e92882d58ea2b4b2d7fbd9a21abc42fa72b751c30f4d1dcd2b1d538a9ac580ad45380cf5586d1862e7b5cc1f5406e07ed7c9f8e2d60b51fe478c8cb1d0419eb74699935bda7fd39e1bdf588eada0a34c61b50952c015c3348b140b862124143c5a6bff8a95d55e96af5282d0d6e75de6b4d018cbe8c314e0e6ddc58b3a9769629e7b668119695c7454a83e476dcde3e823545911058b4a3c63e34d1045296dfd0001a2656e2e929437093f84eb1f5bb7128ec028b07421266121318b060f0de4cc8a979142cb6d18ea76af9e768249b046b79e6c134300c686c706266386af960f5139cf50208344458dedf8427753aa31c102e5a9c27fded58c1be68b6eeb1e7486bf7ae4089f189ab4b2f4cea1390749082ad37909c56bbd385a3d0ca67777c50c31b60a9055f4655ea679c3fe517e9c230a83beb74707d7d3237343972259908c0844814e73c838a8f476238a764c89fdb7dcb41ea693d81e4dd679f51cc7563e0b317336eadf517a3835e2d23ced4b898a4263a37ba4c40655240d10cdfbd608e59a960b3bad9f86945c4123ae65fee8cd0b11b8b3e4ce9186fe40a0d184a7b2814d798b5e305448d9efd326751ac2b5135e377165ba43f41dce4d20a469df4d161006f30ebdf5964229057ea556cc9c94da42385223d8d4a9a68c98452eb4eed7efc9ac9aec7e115e6f0ee4dd8a59f0c3af9025c27263a0493704548204fcc9d9100493fab6bef47752ed0c197c7efec06868d5be4c6a7085e44142d0681f9cc600fd00015fbdb2b09918819b56dda812b5213c0b2ad01cf668900c07402e80b28de1286dc3d073e305f0ab61ce03c3b5dc6661e8607985e0db2494ef615d15200cd441a409d6b579352cf40d8afcb386f2d2e6b193a1d4107ec1dc395c5fa542518356a5d2e60281396b45373a88655a899f0440964b6897e59bc6e4975ca80cfab2329bb4ab9e64d55146acbae756fa01de037ba67f1b32c1e3bbe3c897d442d91f2fcec723eb41ad81ce587f89362480642981290750dacf2d89ca70b317520c13d535438e7b3baab43f0f9470750b3bbae829dda95da000ecffe01a4c78d0c62c15218a88a31afc3ef3f8af914c5975cd9d279491c849265822ee1acc28384e5fe0bf8037b0d912da535403f5b695565c69b8b0354f5ef2a7984ede9b647416cdac8e9693dbb793a8dd01624515b5839d8c9df43185e5a534b02cbf5211a5a76901a5f290d235514f7ede4c384033c438aeb13e916b0b3808af47a069d98c7558bb411464d4f6f47b9a27b0c108ee0105accc00990c74dca25dc8670406c9515b6f381d819ee591e0d496a46333f86fee821a2a99c57f033ddf7f5a5ae2ccad12fd802e2466086269dcd30d5bf166d0e75e78ffdb17314b500eecced0eb89fca7b80e7b072c1995b83aed72d5b033c20b31eff559d64541d17a97875da9075fbc3f2ba535b2cef3b7162a395d150634cab96a316c7c53d4ea11314a33afe53001e41ee3c50081cbf279e81c999aeb762f1ac725354d6e4b1fb7c81630dbeff0b04745dc66616ae25a2454e0f0360bb18079c1c76ad458ae27b904e5b878056d0e66ff205eacbecf2abc3742639611e2b638500423dd50fbc25f9972c1e4e9c2d0e84cd048e6c7cb22b11b3a2ff56cea64b7bd061ecc395a4d56c24be981db2975c234be0b2f81008005b631efbb2fa8782f143b7de38c0de7fd23c704c474c76ae0d36e8995db201572252634de4493f1e88f9abe2635e004a84d27294256a6515b003869890d730d1b9201d75b3fcbdbe0732d4c14543d1be2ecfc827546b7be930d1028972715e99693f7daa4a8a240248d07912d9fdeb64ab0683ea51c2f4d3dcc6787bae732528012df83c70e6e3600f7e891987318ad29b9289ea6b2e2bd82ace318eaa84c2fd8a490b6d34bbe6a75655a09a29835a26a53cb859c9930793f1ec0e3b178c58bb860145c5291f5cbed4c3eb998ac512c603964527e1e5a0f5356ed48b3d0a73adec895df93a5ef9284231cfa621ff60b936bebf4aef2a744a0fb7d47ee5f43085e802f80d547703739c5c00e8dc4a72cf3995aab570f821f775544c3901f2dce970d1b4129e4c28b57e8ff266329e0a37b6931fde5e5c54922291fd305a948afbb18f9effd5d0f60a60d35979ea239b80e249ef70b39f36ee312a4167e0aec0aaf23bdfcd7c53d64249c6351a87f066f8f9845901b7f757a05e0a463133e4df6571981a2c26bd1cbcde2cd0690917112f976ecd8be98df9d99fe49f98a11b45f5eabacda1ab4451ef9cd3d1abde3ab3961a3ad111786470e3ae064b5d3b5a16576834cd64ecab08e6749aa502ff3b7057b7ff751c234ec9b08ea5e4e0b55604114ce837b6718e2ebdd73d1b4cdd2307ed0f1a6123f4e3433d3c3fac7f08a89975ee59d00fd00019612c87cccbc8ece8380fb088fe8852362b7702d24848e60213f33efe5ef71b6d76dd868998e2533cb85964221e103dad6049dcaea215a58610ea64eb88bcf4570a5cec9c88ceaf735f4d77d676dd6cd2abdc1c2a9d5e8d625927294a464fabc08ed5a769e640320af871b6c10c0b36ca09c398de5de8932120719d2e71c481536250d2eac410ab88c4970c68b996f15e4fcbd4090ff80f8677d55a8eb1274436ead2f15cd0f0a141d0706fcb3eab92392ccaaa36bd7f4eb816062bf3539a4b5483191667b1db7d13b5d043ff2c4a105cbf74a5f8450e16ef9e56c521865117cee88c49480177b5507d2916af69654cf760c5c5ec886878c27c2d3c8188072c3fd0001dacf4c63a866d5c2bd21ecd5441afaa3767666957dede948dec007df174f22cd53c05bc5cd2a36ca827fcb8446cbbed912759302cd005ee6a2c69454a3477db36bfac8c9cb9c1bfa913cf4c43f72ee57ad9a4c9c8f5551af8385d855226e2f18a55291dfcc1ae5d60b8a79d840cc539b926be1983c2f16067b104e2c63d8253646c89d31ab4c50f166105a04c19a10ff75cb467ee83717ed391c72464a7fb3772eecc36434bf946de0d03a4d4a5de6c4bcf8ef8643322167d73b424828c43a14dd635cd9db1313e4d4ce5254746fe90672606a0ae15ee5dd388732ccc0d3a4aa489480e6ddf7e6e0556d469166a96181cc439500ee7823f496be42c2950808a4810f484d78b6727e793fb2bd80a89e39bd21df6fb17584aeacb871ddc341cc6b7d10e0a476619f4a1380db2776c4a166acb9109a9a4e3e0d1c722b1cfefc135947ad866938137a8d8af919f4010e380f0d4bfd086059207e5a6155a66325355282a7453cc87c698fa58dc8c3575cd81649ba8ec17ac529ca74957e55b9f4bb56bf00815b9bd44f0df8280a75a1c07da606fbaaa180fd975cf0fb680960c3c3e6574f5db9ee72aca6974ab6cd40306bfbaef658a3613045af4e85edb81331919d3b9fbaf742fd7873c20c7ea1460abf68aeb1af6f3cd8efa6e8c072220c3c5aa34f650d1d946e400bc038be346c92d63a2160048aae8acfd9d14b707333ebe2264080b700fd0001d720e0212231342c3102b8025ed9be48f49d2e97999df5268fe485db4b86020262c62548456cea96a3aaae658ec4fd624269d96faa62933bf6da9f251553dc3085f61ef29fa80e09d651423fdb6feb6ec0fbd5a0e5cb370aec10f239d2858111df0053151f4697104af362d12224e069e0efe3d23f3580fa7dbdbbe2021882b7236e1cb6e589c30427f5afcb414b96b5223fef25d08efe1cc1b94727d05abd7b5fd828d8be4658e0f4467314f3623fbce8484fb58258fffbb0dcd775c78eca1ba6d0a125412db9915de16e6a7bfc80ce05108d8a1a29ffad260498f3d7d505a729b6d80f78cd7c84176c02fd37188433a8481b1ccd90239fd7e7041e2e9bacc880eb7e639dfa8733cb68a4cfabfbd894e153b67e939cdd425a17ef4cdb3c77b204dce1479bc7205b856461de37523dfbf9ea529fec53f78e737ae178bdfc1ccf9eebb12eb9fe4dba614698043db45384cc80ea339e16b8ddca87fb472f2a9ccb2b5152cf06306e3fa48824e5a3fd37e9552feeb586f426737baa0cd20b922235638096033eedc83083b149b489f7cc907ebbaf77e0a7145e5aabe02dc425aee3602ee5a8e33a0af6f84cf8acdc541c7a1626974b21a88088e9f42fa879852ecbdae5df0c7be841051f73c86d5e9af4296da7b84756afe65101ad68ce8150a4f44894c9c3883c7db6b9a8cd7080842e712d37180485ab416d4e4a229270337f4c68491f5457ec9b3b6d4ffdd935903f8e4c036db95e032911714af527430baf622582e1fd97f6581822672800bba9ea246ea960aa19bb71102cea154dc463f4a020561e2a40e835f9506e2c1bbd37b14bf169fee9e41d94d6481153999c6486b28361ac1db2896543a42d0e8d1feb8c9f038e3226b19daf72d454dfd6d9928beccc3b3132d1f4e996ca49f276000e9f48d9ef6726c487c0fb0ede75c69464278892803a74fa9ae56044cea55931ab6c214fe440522aa3770963ae910e8656eb8abe6d1ba0f0633644071a1cb7dc1111eeceaa16a2c3ddb66a555db4c412337dcb28895cee7cfc1dc8304a16fedf2f7de4fd1160d528087c2f55ea33a2c7feca95ca24dc71e1a6f0d978bf0d36bded077f5d55490ee150f2f83ed8009cbe76ecf7f922b047052e83da4706ffc1e2a4f19b3ce0f1c1ca1279bb60d8b94069cfb3fb5c328d63bd821e47866b6902a3c85a02cfe1be4664418a32eed422709c5a536648805b726b053da8269fa65e5945c3f0897566fff4a9aabb4df74fc48378fcaf9cc69d7cd820d0dd682d41af39663e3a12ceb63a4f5a875d2a1df3aa924270ae2d4640e53f8edaadb3f53a8a460dd5c7d3c5cb54cafba1911314d809091d593083dcf397a2e4b9258ced80169c0d7728b869be03b5882045a1afbb0275b1073bef509f2db72fe133df00fbb27ee2ef6ce6fd0e2608387175de108b1fb40b74746a337531375bee5563b9b2ee6b9d803be7bf52b7013f87bf4b7481641b2ab5479fe5bab4409858506ebd120c8350a53ac86e15c8c12485ab9f58c218c9e2f44a39633abcc333017635b19af3330dd4dc275e9848912363a85e7cb3e91ec588b171e936af4a63768edffd74fa05a2fa9e281cff6eb2d801b2fbb8e208dbabdffd387331b9239f53a80414cc223a6230ad96143e624f210d541de65584ebee32586c31be681dd2527d2a52576744ebeb91735f4ec6cb2f4bc8f94dc0f810fbab5e14304c370ea8fa4c208376712cfedf6dff2c4cea55968d3316d87cc68f0ea97eb59e79a5822b9e6e69 diff --git a/bchain/coins/firo/testdata/spendtx.json b/bchain/coins/firo/testdata/spendtx.json new file mode 100644 index 0000000000..9799368971 --- /dev/null +++ b/bchain/coins/firo/testdata/spendtx.json @@ -0,0 +1,40 @@ +{ + "hex": "01000000010000000000000000000000000000000000000000000000000000000000000000fffffffffdb65cc202b25c3200000046551190596d29fb87ee282c1e2204bee5aeb7a1b1c1c28f1d507ca1b5d4f4a351f4af3663d653f8b1061fc77b2b7f72c168414574007b360b3c59f2dddc39519ec1ab30bf290181d1dcd37f4a1e35a24d64937a05be7efbba8c418fe877092be132ec83c77c4098f059ddf947e1aec7e64022acc17bf8cfced88d37da3cb2b2e0105c555a26e42f89f842b219d60ef390a8e998967adf46f06900dd42059810b56112cb23660ed591f4de1eea034fe181a6b1a8285e35212cbc3e0c3f29a138ff6aae9c91ea7abf4e20ce2dd27d7182696963ba53fa57d1eaceafbef2cc814d0b17b19b560a48cfee21fd69025902c23b8ea9fab931a60cf041c09418560020d47a746358826da947e16206a1d35d9879a9d785988bf300a1ee6641d12fea79a3991102d6d8f9b628e5402b0c357de333f9d752df7288ae0e8a60ab910694ee28a04889c52ab6eabc8b890c93fd8129d211357013ead3a8603be4843460cb25856936078045b5b07d1e2570fc2d0f45341827642c3a725a86e07352b2b8f52748e2be7adcfadde26eb9508a93fc5305551b9fda4fa819c1256d868c9b01857bc3a5ef1db57b6351557a53c1409425343abc40754cd121920eb99c92c711c730d838a129b801b2b152ff3b940c83c70addee716160951503eba21720f9859454cab7785cd7f25ecf3846cca6e6c92dd993268c268a3cd1f3d3c3818687f50f5423e658ebb7afdf3f6de96baf2e61b344103c2d16f20e31873d30b38e4a19856a8f510f98e74b819de5f2d208ede4bb3066e8a91d71f4a68f5901755a5faaf54a68316a09fd835f495018f2455f01b6470f8be72360d18baec83e89ed5064a87dd0cee41f57d09f87eecc3dc012f4d2d316544126959484d625a7922f288e1699a5b5b672c44cfaf1ceefd0b4683b1e7a62e9a33bf32412f1a49f1f8a0570dcfee53b9db948e35b9cd545e74e0d024ceb04bf726fe3c323ce002683447beb33788180dcad0a15569e968f185b907b24f0a91a00a237d92a5c2be6d752b27e06fe7238987cf7ee3ed0415a1cd0cc69b8eb586fd6f7b83e01692d9d28b59b9c98c231eb38165d42e62c10cbe4246bfba35cac79f0e002fda3b06941f4ebadba9109d81355ca6d9b0ec463ab4f41542b9cdacbc3c7303b66e5ce54fdb33f1a4e12d069a3154df189ce2f7340d95433de251da4ddf967e000fd69022b80e7bd4378a9be93d9558d63c8b2829c80e9ba75e4603bdcd45a9e100db330dd8017a00cf3d317c770b6d6dcb05cb2cace0e296ce2e8a96b71b0b6ea48be0e2e81cb66e76713a5877020a98acea1230eed97bf80b519b5dca15f724dfc754fd3150d2056ff113c9ffca161e13603f0acdb311614a44a47a2178f46a2017e73fba20d07a1da0a9792080875aafae252a7047154ad590aa34242cc5a76c2bb97c6e1f464d65abb5be84c64589496449f08d066267af9bd40ac5b7b55160f1d2f9933ceec99b3b5a4915776c7d1f5dc2d0226c0742e0c5376bc116aa571cbb692fe53e7bd9c05aa8160d8476d40f5208abf58bae2508bdc5e52ec25fb3a037d17a162646bcf82b6c2dd8560ed86c9a67668a8ade7cce1540d7742400e05d091058fd60396dbd0ac83b54134d64f76303f022da8765a67bd00a0d178a1e97dcf747551decbae17c89c2db17de96220a82f5364504ce7114794de930a35648fbcaeabaf06a329e8e0c3c87f2cae56134acdee0d86b3941d7846e6bbe424e89d8cff510057143547dff7c06ad7326d5bed5de75ec34b3163c3c58a96cca18afe399cef35341d588ff9c15c0c8f5a5a63727ee52311e3f28e3536292ddceb48018b6035113cbb3e838c668b2725f12978e5ab9d8f808dc64ccc0ca48a02c2344e8be8689740c60cd58159e45592c55da593f5f52b1d370a5d6fc364f03fc0ac094f528a67503cbb6fe49513db62596080b728be309f4ada27ead0923de2e89ff8ccea5a00c74f7d106928214e2feeb4ca2bc475cbf3bd7b3458f4d10db64c9abc350e244922519f2d13ddcbeea3f3b2e366eeb00d9d989142faf860823fb5fac1a3e0a72a102c69bfe4ff00fd68023299eb15b9c2892d691c8f439064db72f10d485fb32bc10bedf746bdd83e33f6a56978f66b0f89427a84ffb3f2521841d75a1ef262fbad0547a76deea1151a71b9a39f0d1c8df6c0fa6a66136daafe0b4a205f84df8edb19db8cc069aad6605178c7dd49e9e1af87de1b1ede3fd1ceea73f973ece91ad8ced139754cca4cffa5597bb9fab5fab3d836ee0e04c1ba1077500cf49543bbe5c986a8194b9cb5be63721c4d597c7082d456b23a20ad036c21f416b970a344305217f455925db751f52b0559bd986dd35192f639ee698c9468ba338a7e46ac9e50368eb86e5666af8431e7ae273e14d8202a557d93e3a93cbc1261a4bb13898c9fb15ceb3211f6f7d7adaa30b4baa6c4fea881b84c43f4ee2b9a9111a55fd502fefd95501dedffebebe4fca78fff7c6dd70e90adb7b8f2f611344791968aa3a0bfa06bc759721c622c8f2a4a67851c2acdd586952b84e287f086f60540934d05faf5a267f4ba3f6c17eb15c5fe6f302094247dc9c3d1d42a0017ac8e97400361c94f01c398ad4c9c3f88e21268203e3b52086d796a7147dd039329859e618f7054ca899219485c31bbf460a1b359df1c3a025bff338a365f33f48f71763647e48cc24472edb962d435afd64f394ddab6c6f64e6f54a3568f38ae45ce599fba9314f121eb1c6b8ad3e5964557a058186829a12002b2a9220a1ab55ff478562cb333ef6bb69d4ed4dffd9ebf39ca15f5eecde297afbfd7061e17eda335cf7212389abf1fc13053298cbfd6aa6402a323d5051947347e9fba76b059206a916a4ee84ff1f48c98d9be5ace61a2fef441c44587bae69770f69567ee8f52cd91adcc76250951be53462207cf27746c225e13c2164663cb0ace257902fd5815b878e4f19ff10499acd3700828a051f8c1ec33d421135089001547dc1df5cf9a43da6877472c6496ae65ec1e7b91bc3494769a03cfc6e350c588de0045bf26d0b418e08ffdae019bfb19f510e0e530d66f8173b13826b1281575a5aa703bb86cef598a99b9546e1a241fe86acc5a8f7156542fba23ff41c1db9267708f44dbce1f75465a7befa3e135393b1d5faae4f7d90c480656b0f012d1a66a03c76a58754b22e42f234de46e7f4f05192dc734f497d7d9a1989d657fd1bdb4e2379e4f576c5ee72be808dba602fd3501319e81fe1211176143ac5d9b76a06951a6a0413db2f4ae33d0f7d9a216fe8a5c5828c5af6778cae6464dea07262b1e64f18db9daf24fae038494836e7f96f8056a42f5966ac53f1e3bd7e2a39f129ded3d223908e64e020b7df2fdc275b993ac951921549d0b1cfe6464e8a3600f21714108f5c1aacdeaffd3416e28db6321b761f973ed338e95b559ae9ff6cfcd65e62d5e92b72cb244dda8ab5babaea6b992d7dc5ddb8bcfd189b2f564de4b57e03016f578c3d0adf004232f2f2ee155af2d6d0224799732c61513f10a51405be7b07ccce65f99f0eac9e3ae73a2782e34226508fee3c4effda657412c2bfeae4e4f2b63037db545bb7353b69654dab3f5da6e05e6c801828301e705eed65de092fc7081807643d9d3a84c2c0f00e460e4a7803f8fbc60c1803783f2a2c378e07531ce57bbb700fd3401139803deba8b83a31f7a90a52292c7b44d8c854a7dcdb835a2ee349fd4034792c0e62fe57a845f2927a74f363bf8f01a8a34266c8c3901c32b69f954e08e08e455f19775d92ee0114ead8da754f4403db89cdbf7e2a26d5560b060cfcfca049fc0b4b6a284f3c8b2ca99b0a53e1fbfffe5375cdb81242e758eb5fe13482030b78cf85d1dceb18833fd999d7f2b99a59961c12b8cd5e7cf8b0aa0212334023a28dd3a1211961fc7b7d8583a35d3a89b591e085eb2c63a111dd5ed4fa7b940733658a17e4ebdfb86a9132803d71a9a8b999fd9084a309214eaa5d12c6ade1d5afecf98cdb590d5d67ad79523ab29343643f9d6fe45afb34db61d0d7575f3fa21eac819d3663c5c868b32c0b5fee74ca11dc907de348029cc4f8b9db1008defc55f5f2f7f161d8249f5a5c4e7b643526f176d901a50fd3501be7ca3cbab1bfafd3e532d3cff08a4e43615ccfe9b5c75d661abb778188b62340f9a2f91c7b4e8f921f94fd023695364ce23a1a128cf630a36e69460c732cf514bb3a6512b23878d36505dae42b2680fb5bd293883938fc4964ce807d00a3d5b5bd93eb5328ba05c4ece7a62a6ce579ea0301c8cb04f359d93a68f4752de9641463fa9ae07d1b8ea2c21015539f5687be2977116e4ee99b1230ced94c52486e6ae38badebf88859df164e18ea343305d7153ebf5c6bb8fbbebf3c47cd23411961558edf12b57bf180819412bcc84fbc999fea2535efb01563c48313f12f3f42d3757c5da59e90948878b64f868be2604f8bccc4d103868ad3c9c346049a2c66c590067b890993f7de9b8b229cbe55b7d9c0d3716bb51c53188175fc7bc04bf4b744774ad7dce79d5bd21e4a4c294f8201c1c081602fd3501a925334ef2e47c0890a6a542f8321eef345b2cfd931a0c48c0296b20c1a22f741c3d7a133756ca24ca1455567fb99b6b6da19593a4dcdab7304b5963850e3b79442602217a64245cac37b1aea73afe494057b545324279d70041fe2977232b8a04ec926664ea4c10feb022da5e3ce3ec5a8725192c3d795a614dc479aa0c099f19d13bc97a30cf1ddb36182834deeb42e89b65a6b76cd00b934bd4bacbc9d7aeb0f544059f612d1c8837ebcfc2491fc5e9f1ae8a4b9f08d9877801b8f18c28da4bbcbbaeb8362fb18f6bec531557cdc5231f6ebd4fc73f97eaaeea338c62796b05e0b84b12c8c8de7b0444edd0420c2e5dfe1e6fc5a0c93b7e0ab7f005ae536e9b30a93679b9c5425aced70c1d60ac61d47705744e88b90697694a6b6f32a5eee6b60c4f96d0cfedb03ad96b8172aae6441e01c100a491037d637954ace3da0f416b9364be62df441262e33883df3ba56e9b6f665dbda14a45434e22edc692e0ef977f3d1f902084a3342833ac2ce396859131b64f0cd73bb1be3c22c99fc91dc3ffe07862cae7a34c4384d68d4f729b1b174d55b13e03dfa1fab5af8081d61291da97fd2a00762ae441ee631e242852bc20f5ed8b62a6e4725d977c66b16ebf4daa6511f7070e31b4446339c44d0a90dca22fb29085f2e02884fdd40110ab9262959ff2a85438df9126d869e3d4f7b85044344d4067c7af01979ffcb5598ff17cac8d6b588d9f82d87b8f144bd16149d9277ef00a79fa4d80ea97e7f7e7143246addf1e15e576789c0ad716c44f244d46a02110d413d456f8eb53da3d36589cf777172c14c5d3d56cb7d61471c0a6b22a6dd9f5928fa018ef0577c8dfd5cc5509da86e2a62cab87b5e757e0fbfde1cdf19edccc2d78636ae3ebacf75dbb1121c52ed86dda072db87ddfdabbcaf9b39fdf1fdc072af586e1a091fe00befb4572fac4c8fb4f9ff5f85c13f66f238f4f287c2e8e852729a1aab11188a942d8db8bb8e6483062c8e75166584e8ae11b6685026f8145951f6ac8ca9df676ce965c2f226e5d6c2cb482fd067f50030495d5826cf24d36516ca9894ad2303eda071956582eb6a60e6dbee56d472ec998b3dd3c5d08cf73ced73a7750c2936e23836f36e68544a3b7e02fc576de20e0a76fdb1c13fa6f4090bf91ace61373ccd5e573ee262daed75739f435121df7778313542421441c131cee9cc671fad72b2d1bd5748e6aed813e80f75ed6497522f75f1351ca859a922d1c122fcbd532c82d2a4853a1fb2ec698113421b5d6fc9dd429408c90051f8fab28f03cd7a86c61aefb1b1a833676a33df8ec52b3f697189db992758dfd580115f27596d43332bb625f4cfd5bd5e5545238aa31cc9b706d921f4d8b9184573b9249e3aa6d1d182d86c9a6de8f9b26b71d76d67cdd3638f2c48ade2b47dd60a95d119992c232a14ef05e053601c2a178647da59ad43eb5a4be732e1b8792d8a1d7d9259629ad7f882120b8f4f6984ab464183796bf5980d05bf32d85f61421ca4ff3dfd9c94c5dd3b1b33a0e3b113ab1dda8b2e6fe0daf32f72164a940c9dbbd9db8d460ea919e3f8338257f77ef3e884eb3254b5f60a92e0913d741acf9c173e92e3c0da33af70020649c004845c03018531c5394b3a53668b81eb539981c310270a3c7c4ec25567955eba73d9c37af67abab999f2bce0e14e19e835bda0cc7f5c58851fc4079f704ff8575d44e161f954e835e39ad1c5f9e2a414f890fbbdbfd1a50a1c73fd72ac36e4c2668ffbec8311c76a94340edca158d1acc2c0ea90042149a5b5d198081833bc3f1309fbb7cdf34de6e5dea2b04452f18f8714095ea9c9ab37aa003337a5c5c44a315d77ac8f7e35983106ac5ccee6c21534b87fcc7969e25caf720a6eb4b63cce609aaeec0dc0592340efb93ab426320bc035cfd5901f2ddb66c64b1198d80e619cc73ce127e86ddc9df078d3c71671333c7dad2f0089c65e83070efb0161a3014706337436131cc54e43f0e3484bf24661897bdfc34e64af6d49328f763c164c39e9041cdd3ddf43b1178869d9e4cdebd8e1592acd581a5402f3482c6ae63b34246592a35e9e220055f93c06f704b6484fb7f1b2eb0cc5e587cfa4d4dee683c3d412f4593873ba2191a218d5aadad29d7bea522307be7979158ab102f3e04329846f02793b775c271e7ab66c1d8582e53a2496a438188fde722c48e7f6bb6e91000b05c1553407622bfa2a9fb146dc169b163130baf7802ecbdf0bd059f32bd1a4549fefc9a3a03a99449c9cdbbd45206244fbd9792a69036e8eea32d82ac89694b65887a48308314c0efbf408c689d119ad46ed237c74c322407cc8d499c49bc454dd090802ffc33eff180ca0b3968b39e0df7f8b259cbe95b754ada17686e1530b0a702bca93b1ca42529d68000fd58013c59ca9ff207c4a2d57122e6c374b0c8125176b534bff226a91d7bbea935a07f8602c06eea81ed5ed388524c7a3fbe0dd4c850687652dae368a48bc8ce91711ced188b7da9a7ef1e7d8b96145b39faf8b2e95376cbd173bdeda632b792296dff0df80d4cb3e30fba1960cffb3492159938e0b61a632966284666f50223e3cd14bfb4cc1e95a707677d0ec770751860411b7fe90f4e2c078c11298ba2010c7410594b9de7e6fbe80aea2cb76f8be0c0572defb9d58cceb06dc1c84e197f867452e6a502bb7e0c18d5b1ec9004315563750ccefca4fb65aa1a51aa32773d6519281b7bf6ba826be6f5403b549c3e3646ddff159376c534fcc1e7e339af2ade2e992949d6f2d6362e1c26c70e60ae9669a3a73702afe1c06684794e75966612e9d99cbc7db18acb4a3f37baa1ede7bc419cf655499dac0d126ac3ba833e4aa4822c7bf2c49ed8d94b28055168f4ac738c042b6f21b4dd779539fdd4013688d933c2502cdaed2b4360fcef5c8173ef2c1f5a91604850ec2c81e706d1a2b0c87154380186b812304dcaa7363afe5cb6a52ed235690d746f1a070445fe4ab9a18df19f0d1e87b1a2e9bff724f6c77e2cbaac74a7694366f16620cf4a1d73e3fac311750c406c3fe6c5df4fa5d996d92673571550d694b47b69383e6251171010e3ac21f01f12fe2c764374a3457f34e83ec0c9e87f182f84bf72f1595714c8825a720545a865f223cc3863cb5631c8224bbbf3e082b2c07da33a0b180acb89db94127dbe3c060ef10a8b32298c153aafb1870464eba5414846330f5f274bb6b87e4a2613549853578b7024a249351fc54079737859c559ee066d6186ef6a06a94c19318ae8fd119998b8b8fba2990970a73ace570ae0dfd6a4976c7e240bc1224a410289793d0a97a71b6c60143b2f0163c69cdae4c7dacc707eec9d2de6820b47a6a900aec39f0157e729eece517ce5d1079f88811c6bd1647d32b1375eadd5bcd5b8ef6e9e05b79f4e9fb2497c2d0b1e886ef68b298af6421a7b527357a3cf8a10963d5503a0ed1355ad8e003abd987fb9fe9e26d919ffece2fd1f00fc87188e2a1fd0cfd122c58fab58ba37a61312c68f641908df7043b1b65fe52707eedce969a8a8dd245eb4694e9d01673b1e441d81609b0a91c4ae4f779c7b1838386632fcb1f1dc90d74a3920741c4c0c3ed4ca4b61a0b12195bc5e16f7ea637a38e63f52d0aeb3e4865d1650a2cebe2c14c5a4c2a155975755d0cdd2e65f9ea0dcbde187cad3a88544e0d9b4a4900a590d5a44ab0121ae1f4ac2eb65b5eda140899d5fa527deb95ca4176769f96a68ad3c506723860b0146eaa4360b738ceaf67292a88f4c15f5c91183fab11fa57427a87ccfb1b4214b44c0d2c6d9668e4abe6e4c43934934eb5c621d5b097508411896b343eab7a5acab87607386f907608f6bd3de45fa08183e01037f339cb3905fa8bdd791b8e7d9ee54fc2e424a1537f63e48ad2420d219b14c7025e7d32c0292867d30c023d3900e6aad9c768826c86467b1ebc2ef86774427eb433785f7b5d05db05b056195824d3e40bc2785e40250206fb1680814835100fe5a77ba4cc5816a80b1edd12ee960fc9fc898cc6051d625206d1663c4aad291b5a8b6f9aab95a0e60e9f12f3693f46958ef0fc5ec460d4a5121469a59ebc1b20742c238592976434be70e9406aa2900d31d637dc65fd2de61a80021c54f7dcf90aba4912a73a20038a951127348621ff65add2a75feea07162e63b10021ae0dc0278bcbb2968e8f6f2fa99216a614adcd38433b32b5481ff35082e6f19f002060b1d489bb9b3ee9f5670890d8bf329bdb906955ca9c9b1e23190c4af9b9251320f59505121fcf1a53150766b2b65e55e2b36cc7fc61da94746b17a9b7f97df86e2076dcbe98ccffeac440de898fafa058b7501b07691431b6d32ada652102d55b2820974a8dbc563de8510d65da16fdf79575b59fd2a490177a7f5bd63ff03d48a554201a31ebd30e8c013223a76725afe3d50caa5e1025925a4c03d19dffb17f5d175320e1bf7f439ea8079322a86024e1253cd71604d458c67e09929fe89394402d165020be0d25d6e004b1f86d249a8b4b9e06b5619d165c2057aec4c4bee1a0ee4eb240217beb32c9e29f2dee1bab88aa620d7ed7a7dae80d04f03c1c17ca78e1a9c803b70020b7b27036b274dd398eacccf27a1f8d67fdb3bba2819c5ef0aa94b7c3995464a220487edd3892385c68e0765cf86ac7379a6ba506c3d687615dfd1664a61e0df10620f6e44766be42266c3202569865c8341a8b4a9445769ba336cfacd7b8141f9a9a21f1e28f7a220f0caf78a9ce7a4524d87fb1a8cdccfe6dec364d94ebbba6dd93b2002115b207a64913e0303ec3915a67279f85002410dc25184f06a03b9177f3134695002022c96e73a8fbe87b7755f8f2181f91b5d5348bc861fd6ab35ee71b4ddc5d8a1f210837a3775e5e598150999a4706ec22526e8321f73f7e78d0693595aead84128900219538d13c754a2ac0f1ebbc737d7bd3a4468b7e91636f10bbd980d8253ba5f3a70021182188329afd23ba2916e46880a016b493538ee3ffc4438488fcf6a36d78e48900205add8ddabdab1dbbefb5c2439ff789e158197076ab6b8d99ab37ec4d23e0151f20c876fb7a9976e7b0e8b4fa2d40a26a1f88b5203a992c71f86c863f64409a6c9420ba46a5a64b38094cce0e477fcf526a371f81d98758305173ff85e5af9e9d713520a29a3995c535d10d2de254ce8dc6fdb52a0e6965d5faeec07548aa6b43a91159217f1341316ff39e8dfaffd537063c130f3dd19770d2b911eb407f1c05b42e398e0020d2b3667ad2def5e59fc37b22e196fbda8d2c41b886be1f3cbef4ba78e7fb1b18201d0e660c8294d3550ea90d2e976f0263209275ba6e277ccbec9daba6d361286c2028c77b1955f5cefdec1e35cc2e9121d07651200e90184d7cf32f40dc73432c41217769836ae0d553d95a53b045352d122ac2c489cfb66a172346a3de53801ca99e002094543d995a9f86fb5f49c78fa23d0868faeb3bcca002fd7604fbf81f38c44a712137ffe7281b281b17aa5419276642b8e69eb1b1eabe30ebbefebb022c21f268a300208092e548789ae3e160dbbcc8ad981f80804d9e485003a6c688fdecaeb277b500202d5b57d5d18194fea324bb7c742151f84f9fa7fdb69fac77ed936a56c80cdb5520015264325a4159703b2d38af540c0e680ae700f3b9bf3c069a80696bd322a95521ac7f435a2907331d8dc15dd9dc945807e3ee5ab5295bd574483300431612edbc00209836c6b63d43ee695f135717c85358663d39944bab412134cfd66db5762c9a442145dbfb1f0d944e7f7b6d4b3648659b3b12a4a2c53bd72f9f65e7198957db9f8a0021f8fa17536fd1f70702678ded21a1c7035ca8f088961c04af7c7a4a5df96f0ea60020b7b386e088b3bbb85f1840ff606079ac9ea7f9d0beb62f5c7c5a924913df2c4a20a977ef35ba6c8f89af4b16d5903a1f0d005982c2826797c6fddd0cd2bca1b94c205c0b1932340551606bc9e2602bbfaf633de59ad8fcfe19c4050dac8c664937312028b3e3013ab25c7815169231b9b724e8ae2ca3bdb5fd17487d1fa39046cb77482053b98d7674de0cbba37c37751a7adfbc9c0cbf1b40752921a7d91b08e584fe35205372487e10cc1f1e2d524bb76bc4422d97602f7893c62d28ddae4fc9a896d0372067c5cd6065fa02b76a852744f9cf0b97d32a14ae4cafc94a52087f726693e13921963c3293684a500a48267f5579e77eda8f877d15e4911936d0f8e74b4d38d98700208be980fa8c412eedc13df4b6231e3d2b564296825f490db1e2eac607a355113720397b07219a89c803defc3fc3ac5fc258c8b54b39f53184ee13242feb50a0c62420223706f83565ebce2acd2c18f4cfa79edaf67508da1d2472bfe325e5f20cde47213dee7fcac92a23e8c1c1325e6f086d1c8cd27e47535899399c6e1e4f8784f0bb002014a117fbf97976c0c7af3a56308a4dd19abf6f6a7afb4238e5cd2b41ff3d8b5321bd2e9d0964bab0a1e554eb0a1b350928f2810c4fcdab5ab4e875005cb4a9e69700200e9fee09a4bce859bb38e62a7c74941cb0376d118f1738f06b8a517fb618ec7a20f90381d08f1fa4eca24bccd2e979d0f28710375da371378f74f991439ae08132200b7166584e050832e699ec020e5ae55f07fe8ae4ba7c2c399ef302fb1abf064320eaf6573fcce33c66ea0aab58eef64a3efc1f637b738ee51a95b162eaa9cc476a20e6540cd1e230afdb93aebba474c269c423facf47f2bd500e08961f7c0a4af55320a8fe890159adee60472ed604e73b725c36b2e0a1dc9dc94138a95ab43b38152920d80414b480db1b23a83530d76b6ba4768b612856f328c5d1f481c392bd69f670205cbf3bf512e6647b24098affecb63045ba48ee161913cbea137d89f8c2317e18213ca2d715f1dbf2f7d1cd1843584cee3c6cb663830c2566d2375a8b7d4306a7b3002067451e9fa32a3f67f8940b3d5ed7356e532ab64588a30bc64e68bf0f1754eb6921aab661ffb9e2489a080b5dadf8b66a01b4da585f1d60fb19803d7870aab59f94002009a42d8c17bc201a7683473c104361db25afd272558b431c7205c1ad60e5275720b5259493fe51d34e9e9f13cd027324de99208f62fc7088503d065bbd22eb671e20c2a1ce148baeb48bc4074806162c5081bbc4636a01d2947e2e511a8e23f05010217f78c01cf3b1de88c2b5efe4f1a44da7b7ad1d70de3a9ee75de52c21f5d9dda10021b1e53880cf898cc3304af3330d0dc20424ccefb35751124b925e132d89ffbb840021ddada2ffe00b2b281c447b8d03562bdfaad7248bfd3b82ac74178258c17f629700209f39211210bbb04910304087e2907c3a8a12ed4142aaa866b6916b3f17d1c3232113567eae2f96882409da14e61072dc3941a7592b816b25b3d52f3ccf8ba5499500217e8262ee95708b1b40ea9644b6307ff3886fa0159a3e28d6155e3c4f737e2a9000208a5f96711ed5da026ea1c3e40ec96a8c5860e871ca599c9ea740e3ceaab2480720cb25ebb06c94ad22dc7d529f3296366d4f65781e165de8cab751d4fe464da97220269dcf06c230675b405eec3bd7b1e99d9191242bb0d8f089d31f5d41d61e4768214635add2924737b775e5c252b8ec10a1d072ac4ab941d8745ace8db5ca7c72a4002025a5b0916a1b7e739c6926915cbdba2f4fa3b8b2728a7030cca4946362e3e84d20c5b5d7283e047fd80f2281445463424ac2a6f1aaf2053bbc3c136254bbade21850801b49c2a7eeee02072a84d52810a6e308b5b895f082b83827d566722f46f9dcadcc7437e6a5df1f12cbb56bf34473a0bbd93b18b130a8a3b98a08ff3212094ecf6309aab5bb96fc39e51df0828b70ed423b3ea325d175f412bea1f96c89ae4459987ad12891d24e968ddfa4f4c00e2fd4ee2d08d2c0e6ad48129c32fa7bc99c3681e3f7996a1b93387a10520949c62c2c64a0ec1889c5eb5c1313291a78dd7213244c21eb9a9da1b77c9ea77880305bccd24ffbbad2883c52dc411485b64a291bcc1440f9eba8277d0d8db1ebc00f874f52b126e99fa1d1ea5174c2556085a46a0223466cdbc23a9e217afdd1de8a60be75e11faeda6091a37299745789b6ade6800081d69088cb8d7bb502ead5f1955391de9d7fdb577fb2da28195a81f6902612316ac9f15ae160b2977310cee6660ecdac2fe9f801f9188635c83ae12a89e3aaab5ac05d3b988fb6854f17faa24d0dd9d29d79489ce3d453903f951a6c83bd4c5874482dc6b0e4883ffa65e4a955c45f7fe7ef32f5ec034595c8216cbc62393ea19900818b43280d3245ce70caa22225803eb986dc3353c37d798f84761ef12a56e00ac6dcfb4350a8e6f108b0f10a1975d0e47508730903e94a2ee8d9f36561d1fd2802bcc103367e15e325eec1cb09c86f40d632e9bbde8b2f6006b4981fed1772729c17d1cf3859e4cdefe9246ff6f6285450b520180f04665c25527cfc85da4596bf00804399c22b05bd36cc68e8e7b5c2625bc34806eed211d86887cd37742f1108acf1f06278eb9028eee4673e0cadd2a5e1f5f257422afb0fcc199e65728ccd12fe689ba03b50dde3957bb674b01baad178efa863bcd10de5235f3fbac3062933488e9b4a60b2cb716c5c2a9648aeba59eb3e50ae3be842336355c36231630a918900fd0001c22157003bf3613cb4e60bc0842ef72d03c3927ace3e35f79e7975e6d93593c1727ece0f9734776e3fd8354869dd0c2e36d992e493524f97875a5798e45ad8800d288ff3c5ed1c656298547b3f386690d20d323daa40d684b557ffdc2fd64c2f3f71938ffad426211d4e0fa1ab71bf2eab2095a61868ad51bc622506f95d2186870b9fd55fadcab4734a96bb996948339408559f1ab3d0793b6ff3830c22dcf8387590bfee93005b5baf5890bf9e3c925d40906e714205aeddb42376eda4f4ac7d96bf9a74546ca377bece79b690d870a560c3b1c4416b06bcfba6904392ba19214fe91184b7545019fb8a5c65e0a6919720dd962c91f98992177eeaec4665b6fd000152c7953810a6139a35d9ab44951eacb6d7f88b6a2d0fd1a05cf109d8f9b8092d1e970d6ef12cbfd2f8f901baae01d8830b8cd521e63300bbc1bc623fa5c0e48017333a631d42b0e71d1508e7b8dcc53fb304d4480e2a4e440c9e53204482c72d97b4d8561306d64030846c9027bf218567d607c4a2304df183036f1861fed60942ba64961824b80fd8a828499888f80a11cf91ad2fe187aae73605bff8a4b004a2738d56a5abf11f9b82f8ddd501443545bb4aeb49fe39b64c7a768380892c6f00f8cfb49f4594e1c88ceec1125a3b70e890150dace647307c1cfe715642756d5d2f6c28218274ca5668a3c2a4af4b79e70af8b83de56337b841c94dcef0c89ffd000109c0d936c59e7b389dd374956c37ab4b8978cf0aa5b7050dc50510f381eabac6fa2e91934b72e798eae1f6f43be168ce1ef600e7b9bd1bbc2c2c963cc1c777c41ea9ce7cb85dbb140bb01cd90bef6298783d8c6c056955eb83b7b9df63ba4b9cb4201cfc83897e54e269398be9aea1e293fe7131f92d22b1fe8ecaa22cd934980f4f0e1b8a91dbfbec640010d91623780a2e7647391eadd5a10bedc3efbbdbf189c33057605f1cfee70d8ced664531535abace6d63bcbcd12774d461c91e4c836a9534b35a735f211cfa324d74febc41fc9ea3e6e953ef555deb6ad348e35ef3be21e32d2546006d43765fb7275d10c47618d109fe806a4f94fc67940ee02aabfd00017585f2e215c1912e88aad895e87882da714c625143b5f1a9ecb9764ef1e1a1e654c08c70a2208e371f9c4b2aca734bca273072eb9cf5621c73ed442efc85624c10b0564c96f488cd5ed697fb7ea414c8f49f5668c4b41227cd57df071e004675cfe16914c9e3e018ac7e0b720b9cb9496f2d0e176ab2d611ede6e80ef5803566bf698e09b80a81c0eebe58ecda39093f0c1651fff5aff860c4b2e70460bb95da3a74cae7e26139d1b257ed9aae65dd4d86e240f07ea77f1691be722bf9855ffa759afac8a1e6c91326da71a1120092a914507c2000a167966a74c8e5fa8533078be90087d59fa75405168d72126667458525b6406849bc1bc9a97db49a37d084fd000154e8d56136b6dab9d5fadb3065668dab000eda7d2a8c47b342ee5c95281fa8e2fcebcad5a0943f2ddbc46390eac974b4b27ab9da4fb3747917c22305d3ad91b5694b312dfeb392b55df60cc8d4f6950bfbf4d5dbccee860d9997d2de34bba2335733909110bb273c2e36c15315fb79a93d1bffe33c358e2da4c238e8ae734fda09936a758f0713f720bc556381e41f76c29b7a02bd44926d5b2a7d818c788315c253a90a03b9194fbd581603e03a34bb298d8a6f4021b887ce813f3cccc17a2a7f6bdc5b50a681723890250c4e9050694c9a66fc587187973f209c1962bbc5bc7ff64fed7d6a171981b814a80a1cf3123a8dc622008cfac1baebce0dfbe52ab080209b50f0445fcf9488fe4818e96d8556331f20c211c60e07f1f80e3ab23103281a07c5df8d85c6fa1767aa997ab2dc3bbbf1533ffa8729bc02f6ed3d9ec12441576ea311a1e30af774c92f70f5d4521b1a67d0b7c1571c45e23785e70bcbbae1da98f8e2eeccbc67ec771a30b68e37f8a385820bfdfc7af405bd5375df20557cfd000167f18545407c8f34edfe760a91ca58479b4caaa3964af57568e4fe511cdc94e99919ac76e43c423dd4024457896c2367cb62da0ebe7d8b98cf79981256d870421ef6adafa61bdd61a9fa752a3102bdbe90ec1f9ea1402c855c2a78c5c09ee8a4297dd815aba0b346eb3be92a04301c33c83b0d02ea26a4eebbfba0b71667354bd8e6c825eda303b05207062b3b909397026f469a3dba5dbb851bd28500322b2b898efba194e9c89a97e378691c6c3587f7fe4bf1a3c69d31fb9195ea9ad626406f33bb39e8083452035038c1714d2753ffd79f62643057bff804d7693e014a80a9e32d1db6e9219e55ae5d59ca7f9615b252132a559ae8f0ea9bb70947170fd3806fe1ed3b59b8df259900cb793dd2f745658cb2cae325e988ae4259bf3674b40d952737b874531487ad58a38fd6d01435d4e14a87b0fef4ba40a2c985cc62ea2d3b6c97453c8bfc61ded2026a403939615b94db3ed5388adf92480ebe647d9c209541b9ab97a67f8afa8ba2ddc4f6621eac975806f7a2935a4754ca1281407254fd0001ca584567228a05aff314a6bb8db5d77c64cdc98049e4fc4e8a0dd02e94d65e494a83fd0573bf26071fdfce8455a8586bedcc9ec3912fb93c28c97c76fd2bd8cf7c77eb032f1b3d5f18cacb4d6e46d1d636e5423de333171621ac4ddf00cd140c8a31cfe6e1720b702f5977426ba0f341c5c121fa41e5f9cf72c676d7d8840760047baeef41a85ee0f58650fffa0dfcf4a354b4fd635f65d533afdf68682c062fa1ef3ed0345e0e6a4a03b2dd3fb6c1918fd4c6ea2e88efc1223bf72d33a12ec9f10212abd8e0d323fefe127edc909daf018a59e7be84b92ad9506be6cc080fdaccba9d0e6153e49ebe546afa2c3a5fd37294b035eaeb5a46ffb1020a5fe683b0810df474b799476566c1a4287bbd112cf2fcedd1be2cdb8707c55db9af086106f66a061f2f39e2ea3bfd1cbc18dcc049d9011336b2bcc240f731b3f45955e15d228656e7d41424ed16096607d48dfe0e2456d877645b5ea8f006b797958aad495cf7d57408484038b87ec99653b7fdc5b8a1ebf47b7883218bb9cd52eb8a22e49300fd0001310d818741b56832a1a31e3aecde85d578e6bef95e0d3321278f243dcf81ec7e2e6780e1bd4de223b5835f57184ea2c2edab2b870fb13f620b3124f2fc83740c26aa30b917680eb4a61a3d1e455928a6325ca2c330a74f35c659dab9219fc1ad2dc4fac28a8055bbc1acf272e294b21d1c3083c105b107e9ddd14314926a5067dfad3ea37c54ed50a5ac96391dea5fb553fa689d4166b8547b0af7764d22b31deceb9d8b25bd2edda13de0b952e8c062504896af885bd026edb9708bdb23617f0fc68a726432ea1c929262c82bb2be5f1536c6f88d33b308f8c929560caaf74b8fe5f840706e3e0b81bee0e46cdb134867bf8b11655fa204759bdc88d492eeb18093dce3035bac48a26fee7f6f5ac66adf63876ef22300572f528d5f482480e951befca94ed142ce71d311a3000d7895f2d9f688edd34ca44a68a09cfc4b685ef9f5e8a6d75e956e99a6bd01bdc002c94cf87861518df3a5a0713d7ac072254ac1d68de90d6a521348969bc2d59fbbcdef6918c045701421c92ef733b3b7c4a3678026ef6ecbb093dd60690ab9b7d28280a81598793788de0272eb52423c3b5335c844fae3a69374757a3b41e3cb2250e36d5e185eb2e67950782ecc1d31398965fe54f680c52b1806bd764fa2926377fa6f7f909ade7774b8a91a65049bb6862048d389c3536be88e1800ce95c0ef3477fcb5317ac511b78dde2dee12fe305773188132574bb60a5e68118e2373411b35dd42ca882b7b833c4efc20f1f3bab6cc6ff7036d48b2051bae2ac95dda94cc330ec1d0e3e09f856c7e36c44020b01a5076268aa5ac517cb4c9e936f958b7ac6f8fd67e961e083487b2befc7f923c559f6c52309b677fb090a604a6e9454c2461b2fa1574403fc2438fdaa1318c606707c0c600fd000124940c5f2606ccd649a9988afb6775e60891f95b91773924d7017af430cafcbd0484929b049c7a8372852bb695ce1748bdfbc150a5ca6a1519c06e5982c990e6b22f509a606337a647d9d1643522264b5838390e716cc8bd4f47bb8a0de23577b998855752c434efe432595f63529bda7c7164b321304afaa6a4adb71dc05c25f5e5294b69b21c75a13edec9f8c0a31243aa73ce6592f1bbc84c4705daef99acba57280dc92de02e17f1b28473f200b3e4a8e577312e51f1f79c06ea49f9f1a27eef83ed0749d5eb6534f9d8ce773e94f21407cd17154c644d8099b4edbeebf4401601d3e3667c32186ae79c69abb3c72c0e8220b2ab9304d1307a686c9db992808823b2b219c9f81d5a641e40be3eb71e841db1e43d571d3b225b5d811e9a0101b37891b8a962be19c7b127961ac447a847de4782680d3ced69df0c4f032ddf36d9f7da47aebba193b703598c12c2214dd41953a8fd4c2956d261c989d560d09809e6471d71c5ccefb171e1b84b806e1ebb792b40fba818c40a8ccbd07ccd5301fd00011813eadc77aec30750c84dd27a1dd089ec245bb82d93aed9f343f7cacd9cd49a22a4b516df334c6981cf57d9038700ef0eb610a70bc71dfb1f4d74ae3359835b67090bcf46549a2f8eb5e9d9573d6f2900efa6164528cb2298d488b7ba8df39748f6fe41ae04028fe3e171c68cf7954b228e0e54f266f447d9a93ae944517fbc95d02e898ee7f3619a02abed25e78cdfdfd3ee09521a5a1067790117d5641cde06554e7aff909ad7f7d8f67dbf9fe0ebd1f75856dd0face6d53b10230d2f605b1c1b022376c2f569d9849bf094f7b47e5c1aa5f88d3cba904de9fc2299ce60672c59b6b951ec15809a78e2beb4b64db2768a44d253da8268ba4d6b1517b03ba980ea5c2231b957018bfcb1dcecf26ef89a5338976732dbdb6f7354da85b62d1f0064a9c99ffbc36228487ecd45f4d605bb3a2f0113b756f61bd2d5bd7a75019489fb9b4807f90c78004233c53031f7013ff3fbfe9f37cbd657c61e071dc1e48a5c15f5b1cde2ceae555497228b19d2be443ef59e89067504c76df6197e899aa833fd00016741248c9db871fe238a8fd2a153a21d659c82caa6d0d5597a900979d10862ef7bbb9643b8423a704cdfc787bad8b06f693e0279e399125db92681391a88cd1f8a3b9d620fa3d29071d4b540fdda7d24886beff41e9624955bef1eebb27505487c6b650f941c5487db2d6a9de360fac13754bf6bdcacf8f5162e78e1808c2021468b402d2de932a590a22371ae513e4f8385cc3d54d6d8112d30b5053dee2767bd5d68b1cef07c5dbe79be7a13b4dc761b303625a35ffd50cd1a7a7607c34f76b737cad0a77c991efcc0f48ec0baea05350643839073c4d912d7f6d18ef80f1c42320c5a1949abf9500e5e027f84dd326ddc25b796e885f878be522c987a47a1fd0001892880727994f3ef4cc7bc3b009d3ab0ba12e6fbec400d978a7b094fcbd63826e5a2dbbdddb37042f9d488f82e0f6d64bcf88d327aea5615c6445c13424544d45e12007f26b62408c19eba388bcb27a32b549f048cdf1a9df32817a926ab34e130848792f71cb81f0dc000f5b640972a5d1180b3876e2c170e3ef31e27610e5db6cf50077970504de9b6354284bf12106151876524752dc34024d7c353c8618c1a0f54b958edeb421d5d521470bc1edabdd5106b7f89c1a5d52b36c7491d76d6d52c153e17e692a1ad389a57564aaa352fabff8b65dd9b18b6e76feaece6bd76f09a88d60cc344d1865aa7b97dcd9b7ee5d869943a0aca6189289cbdfd9464cdfd0001ba441f650b1c2d89d985d28731ee44502b189fa4ea4e283e9ccc8f5aa2026a3b761d9c83d2823ac20f780b887d2487f900c5f568f2059f44c2014b08d7886be7d6654dd8c760d82a2f4bc80e06760211321638b0c676bfd4254fccb74b497e866d33885345ef0a990aaf150fc2d36ec3a7a2b21b3e10356b6d7ee5984273f34f295ad3c9ba5ec4af588da45a4b512587181a89d3cf11cccbecaa9a590396b63f27ac3e157a08df9aa19867a7729910a02ef994441cb0a733d957c5e41351f8784776b45246159733c6818c817f4b7f219a68c13dd02b0410b037135025fd5a07f320f44a21926c5c243636bbc3a0f437294bc019a8bc8e9e14795ea712ded2d28092f38d55599ff2c0dd2650de43a589a498fba4d1920cf9557aeef01575efb7139b8cf10b6ea5ab3ef9b40d4a90977ab89c55a5af3fbf0f8a72197abc38dc6d6df406cc7531260a8d5e36d3ec1ddb95486596b45977c1559892fe96ee1ec87d54083c8e88fb75590898be9bb956ad1593009b68285fcd60e29a392130fcdecf3680c4f08fc6aed56784e471ad4dd0d0146e0fd41c4e17d1e660daa6fd01634cc52a48fc71402242ad1b9a1a42f254b433769f44f895ec40119b40a9f731e07d5b5a08b65c2ab225a933a92889e4da908332a35de27bcf88db00ae7f7d4fc3270b4fbe1cddd3934864c12b77d6f109704e9c0835742abcc29dde4bcead8b0c0a1a08fd00019cd781470ffc9915bbffff9b297207280aedf02ae6ce1972e2ee4f5959aa022fe22098b388eb542ca03a1af83a0f526eeafc95b192c2695eb74d1e55f6c61a950402deadb11bab08257124b0ee26ee87e87570aa7c615eed73c12862708012e2ad8775444fda2687455a0c2e79d9c87e2c8b6eabcc3622c1bdf14f94747086ef33aeafb3b282b1cfbfe7e20bd20e57a00cce137c764a07a8f25e96cadf0ac678eb79938cc592b38b299d77d3d2dccf1bc3ad3da77d15bade71085176833fccb4f4d813b43fedfea06496c732f0ff2898fb0a13ccd272a51da2c7e2c92c0b47106deb290f12f1927efd87500483efc37b35bcad9aaac18b7676d4356e9080cead811cd4046723cc636a017890c78502f131ed1c4f2bb1c67f5a3095a1f39362b5b1d769e202127eefb4c36b280265946cb8519af11524abbd4d0d35b83d9516983b7053f3c5a583f7616ffdb271612030cb06448ce5aaa264ffa9dab04c64cb246fb188fd20ab61d2e39695d47564fb8485e003a517b2f6267c9147da7051ed4ec6008140c144235a6b9076e23b97a81e347c8a367d9c12e0d775a378337eb3b78647fee80b99107d47307ae73c15dd22a4210f98a5e4b7ff6ca8ea79286ee5acded439f8633ce730ee68947fb12a323854ae232ce75fdfa99d274926b14f81e93279f5ef42cf7abaf5e4db9dab5e1ae0442e9a4816d9df5fd1c671ffc2ff9886c50ca400807db6e16ac5cb6fabb094429a97f7ae57639537b30b12f634196798cde9c00a85fef093cd983ad3f4d9fa8cc2168a8331f07fbac52c2544defd008d5d31905a6b4f57e2b786c091b019dd4a23167a457f2adef68fdd71a4989921698a451c323faa2870b78f555c3c30a56319393d5ba640ee9b0c43a2daa29c5c080b4dabf229814d08f0c3c7e21b9fa04c76c7d6c3f12509c060015fe82ffff8eaed0caf49974478bd49b94e1f710945a0c233577808d97d435f09f9193b1e7abe8aeacd1f6f142c9eec20d7427cb919262b8a81372af0523fd6c219b427477da7715f7f07d48f890b751206819b8693faf2c8f6b1cd42735ec457051022d063446c58e9e23a940081d24e2fc309cf1390ca944179e6dbcd7cb4e39832f3c8cdec876f8d964cddf3c27f87802eafa33b2ef393e59fdf9028f1add7988eee4140257fd5b420273d9bef73715569b0001ec2cbcc13e70f3be6f0dfae99b504ff2fda3a3bb4974ea056e1fa739eea46d38af1f2cf3ebdf32887e7ecbb570a3633c5e2425f29d24a5ef1cf00fd00016100ce85d6fdca9575406f04fbad2d9cb4d7f6fa1393be3c3d6fbd5eaf7a99a02f42b8c373bdd03f7c76a9de409f943519dabf438788e2d96b34b7743af43a0b01bf8a080615013519a4424a5ebc90cfaf822719f1fe59ae708b726307243bf7cc399be43050e8b9115ddc1cf4c2e0c4b2a6a9674b1b45584d7b4ca779eaac889dd720bc46bfda1ba2af747a8e53c2b3b857e1e5b607ea5fb45ecf8980250056134dcdb481bd915bab49031c6ad7e3faaa58e39952119d8729e317e15e864512f0bbcc6898a71cfa619fd5753a5b32bf98dead20f99284042a0a661297d468445d982d9159c44fa344aa2cde29454c7b08f3ff4671f23a4d062ec8508b67dda681c0fcd0346c255f430aea7066ea78b6571e8493274db67d253968f033f88c42a90f40a8298aa3db289f0e5ec038201892272b636d947f6ae9c6342e5f081db2b188a9d3f50b2e8fb396fe189ec082fa63fefdb33d11dc2771fa1fe04b23438cce2bfedac58a1c6b6819cb7b02fed3d74e8fcfb839df07bc474ccd8ee76cdd35bd0081ea358b44b3840116487e3f85d40ccef06d78c631423996e991df95018dba5db3cd0c639c4e76122db272e4a59cba263a26cf5877481b93714bf9d78b019e7493443c73c5af84cb6e9837b6d809da037a573a6b68cfd8bda5d02da0c50743e889afd72406eccbfb255f957ad00fc76a8c117d182363dc9e914dd3d6cad81e32bf00fd000133a5e5ee9bcd22e54c18d8ac925859144d32339b2bd00c32e8f98b754cdc3495143c425da51b3db805aa3884ea27c2ac815e4f5575491bfff800fb5a3c1fc0120fbc33d53ca941115350be844493da6e3dc40847fab916235bdb3409a356b9528278102b4d96e93c27c88a081bf0bfc4462ae6a2e340832ee0c76aab12f192f58b4fb5fe386cf566a762f83bfbb88d1859a10d08ec78b01536c3dcb69e9a441f9d2e947e898dda40f57fce5f0e5184d93935fbde32cdb750c51d57cf7c2be917fa299a01c41f2a1018d435380632735f9ad2e958cefc8837c21172bfe67a574db3d8223eba4d0327850bd5fff38459d4fb6e00715ba7ee5355605ea0de209ac7fd000112ffd4061db7a6cc8477b6145a93f3e6fba20a368be255f4e435dfa8c746ffeda600b87dc3e5fef1ffb6e917b09319853adea9701d795e244c4937d25e0cb0c62bf4f69168aa82ca022f6a28a7b85eb1e8eebbbaab30a61c785e19f59232d273d936e4ce4b9c62ebb3ee2b96e90a5a4ab633e9b3704fc0f50ecc5b9ad6f3843576aae92928ca7c8d09e3b87a6281328a1482bb642005cec7dd57f3ef9b1a167387511eb339412c109bb94b57c4e46e3f33d6fbbdeee42e60ebc9f44f7530b86a94bd9a9acb974c76782e954e295770716cd216b83036fee452fb2f83ef077d673f1126ed412c8d9df216b0cbc72456ec8e2932de9539cfd562ada45a389a4d8f808cc78aed67e86adc2cabd1bdc0b21969cb52b9b1f1a1d808d67d8e8bd478f293d9b81bdd65949f5ea0bcf48c6fd5995ec992a273f6e335e7e6969d008590a961240af5ae24e4410dbe1c6dbd001f77406731348a0dc4ce2f8a683e4fc7a49c659207ca2bd8fdcb31d39e58a8c75f76031d5b65d96f0f1af90da9306f019332558135064b7f64dbea34cd68c039d4dcb703f63a0a1effa946cd1c9bed59a956cf85b358f4db3118070265f8aad6c540b066f29852e005003666316066b324cb037b9b5fb6ab8b2908b1b5509e9e0ece6345cc571c84f83dd0efc128ff27a1b5dadf0a0921544e9490d13fc82df1939382f1b6170e8ca99b38362228da418dd219970081840ec70b20c096e1be5b162d058c5c6db0b672d4e555effed3ec8ca85dd69afaca09d85cc206d179994b6a0443ceb4e65071ac7a64842c8a6b2eb8f89519dac433829865747586af18ed1d8d864b75baed59daf5d08931cf47dd2c802545505c9ae8d351ae00efb35c5725f8d3cfd87c7792b084096af67c7f63bccced4a038d00fd00013449e73edb7c7fbb2df81482f1d22d02eab854f7e7a1362e9a41a95d0a8ab7bb68816dd19776e0cde728b6cca402b4271b384f71053bf501ec8cf40167b76661d11aca1ddf4c47421be72577dff8be5ca3461dc8cfffcd99fbd7accce7ebfe12ac6c4f8d265b1416464f2b94cb93b40957eab9e01bfaa4e33948fb2de3cb093db74e914c3f048eca8699735e3693752fd59dcf48cc92b63594b8595052ca405ac9191c6ad3cf08c6d92c384a8eb0b643b57e8b9f91ad94ba1c7f5d8aeff0baf1a905ae1d722af5d1d4da2e56694a7857f99c114eb63d6914b0ad466b6ff0970457730cf6ebc607b1064ab3e792833de818ce7f47fd212d98dedc8602d90a08b281fc8f5ebc335769ddebdcc8fdb0507d0d814fe95333b151b6518abc1221bb431aa83abaf371451e4ade47142b1c1159b372fc95380aa1697935bda8ac28ef6bab6c69d6871ed0242087f1e69f4ebc71066bac94040f79e5fba35c0bc9085546634d5b1fd7f5c85577fd7b645845ec87623eefaca134432ab7663dc7f5a66f55a80081cdcb09b132c40a0e33f8f9c47fa993a3d3c78f5b4d8b7c0ccd2e343cfd78819fb9d6556e3ad0acf67c85cf5cdd9335665761a091200ce34bd81172e0bc87efdac66ed1d4d849f9b1e94ed2db44601b8f07d85a173a6ed9a76ead0a21d48421a608e17baea8e9a6b319c0c41fc5917263f6c93208f9fad8ae2b2eea2f702a2fb60080f98b3379369444ffa8fc207955e7b01575c7007ed19ec7291f28b93db7aa7148bffb9f98f7e3ec1e1f21568ad37f91c4603d3f276ff0fa7e9ee8ade05c277775c3ca2440d50d427a23f7aba81b8b1b9bf3ea8fcddcc3c3a7708601688fda8a6f41d0cf7b5aa4a03422f332012ede8fa6a9d8093980f07b87092bc6e48d5ab343fd0001840e370e28d6cfc75778bf5612efae6c33b4dea0c4964e810fec77ef1875956edcd2e22139a485fc4515fe44c57149905efd72b16f4b367735c1e91727632507aeece411a472e4f270e0bde542aeab9961d4fd0898b821a6f193dd42391de664331e33b9244e0598669269c73125b21765f048e0c2b9d17aeb0cb3c112a318040ea4126c43e0f46d1d9c1304d95f35b875cc2fc3964970e3602cc51f2c496f108f904e2dcc8113223a9a074c344b42662c3fa22490db6ac63a1b9abf0dbc97feb0f4447eed2e96f33f854f695ce54e24b9ec180cddc752cbe66fd361d874873ea3733a4ad92870b24efbd22e928deecd7d4293b680843d75127c0eec3f07f894fd0001266e0fc8977335a776a66b44c25dde8ac3f40f2d195dd3845a0019b5062043d1e1f24adaaa3052565db20717cdbd769bc1cbad88c0fc6a205fa4acf472f954d161c3450cfa0c622b3dbdbb2813af60d47870299c1f3d793302c678a2d4cc7705ee51b675dea5955fd7ddf184cad643fea3f1a428b6d49da35ae251744cca95446811aa79d4edbb1399734c3b3d71c7ef455eb73f72ff013938ff65ffe3d8cc8ec321328775db8884cdbf9645cedfb4d86bd54cda89c7a4da2da5dda4d4f459518e058d3614a4a9475426c64a16bb206d2a02decb9e301ede48dd0247d36d5b9fa1e449f44f6a3ea7999f31259bc49ea13b62cddc0f877435901da6f9cde2e4ce816565e8a37e36ddc7eb0d1363f2d0641db79c0da0fc7346cbaada28e859cc7bddea3653151df18260a7609aef925c9de21299857732ca58631eafe768ec58752d63a48b65895e428bb4054b8166e61beb6e8127488941601c0ee1092f695ca0fa9d7b965eaaa4dfdbb9fb2127a75447d02f64bd3c4f4e285e781b02d98b21089000fd000118a3777a8b2c2a8e5e9f7fd85ac71b6e843ae5ac31d3e192b73c830a33eaacd7ed6f9d5aed4754b9b6af55e60dd31ced5d74d2910c2e9a500dd3fd8e282136337919b3e8faf81a96315f04588f7a86786b922c6ada489eb90bbb8b1dcc85e8f6c6d3d5fc561f4ee579e9143fa4726cbf168119fa5e2b1539327327f00ab363eb065aed240f5d120096951933dd3e689118d2262e01e5827c120f53f80dc8c7207f2b633aaa0ebd350e65882d7e9069190163975682eaeb8570c6c297b614a72ab6a6c3276f754a7fec8ef86c50ebec46bf88701ce0c3037db989247de5ee7aa731ca30af5bfe9357e3f0de64160b9ccdbb0eee9885478a6e9aab6902227933c8800ae488d64c37f7e8713e92a1ef4da540c5da96b55f67bcc7e5b3a0950e75c0e75edea568a951e213d8713c034245f6d6e307cde14b2d7160bb2ee6628d3ab486d2a0a9c760478b56003f84f6ec15c6ecc44ceabd1d1007cbb891c005f62fce138af30cc236c0e31e0d93be2f94b9f3a7ecbff058bce5fedd4bd294ef8bbaa0588002c6177026c1525bf82d44c5458f84a9105b3f6eda233d83608b024ab3aa3646a9baa480c983df90f90be078d68a80292b8a609180fa075f93e99590018c49e8eda3098caef604bbb643ea3e4a0ce82407a80a13e5f5c786746571e74883e7548d881a9ad516d0b7ae5018ef44a5a93e227057169302cab4666a35b3b22ac678fd0001df3435a26f04bd17de1cf54277d5e8b52d4bd552f2b27115e6c65a252007b889c3d5178dd15259d2216e0e3fdbe065b38b5f638fbd77ade95f13882aeddcf59d508f0252647a0d705ecada91727ca37541f25aa4061a9ca2e3e3dd2169ac00a508db5661b41cd1b626a5d64e9a093b3509d86c122264b53c5fc95d013c6b8d58a9faf515af46d5d41610ab99555c2c3ab363d604fa147b0f1bc86a3da26ad8a4614e6a27f84f58b02b698c232d6a6d864e49d1fc95aac2fca78e1483c53c6344a731ea31261a19bd5b7190d9ccb8ed161d963d4949d17bdb8edf19d1bbd0fa9311b2d5f2b3febc5e4dd6e3c6f2e169ac81a671aaad0723ff8e6b0228ce57ef8e81423a9b6290dbbabe3ab5e8cba4c4e6453766806e946f6261557c2b23c05e7aace181919a12ac324fb26f709ee0eca8b746eeead2b12c13f01c39d5b117bdcdd39fe102d66bccda50456e8994ef9924e22ce634ae801c9cf7ae24d72fd568379bb6650f77af9dbaeacdee02719fffc07ad660fc01b84dd88f35e6f97ee3c977b40080a08b918b6a42c1e2cbf0231135b5a666a371bce41d2b53ccb47dac374dd8a1b9d0469281570672916c4841692a836200f9d4dc3d69b71fbf6a51ab597c6b11ee6d772fdcf02350817e4d85f79437b5aa21ed0407fe9689128f9166bc391ed499357d91b262930834e96b1fa8505ebd15eaf85ff38db7ff5e9897c1ee9f5f883afd000161acbe21f4e6258c082bcca1879e68a20989c95cd5f7fcd1817b807d6819263f1d32cd7882282db7bc2a94b5ad3ff053198fb7d89b51c0df65493652932b4af07023ac9a84793269798b2850d1296aaacde7fe3eac56b88ea9f6656e4576b58ab9eb13dbffa731c6c4a57c2d06fdae1ca33e1fd07851afcc9d5c6021a1e0b3524c72bac8c9ebce52290043b3596e9bd0639220d164fb41017e08c632fa32c798c61c643af591b15148d585dba6baf61948e53d74a42afe3e45505fa249c6b6429cb40108f107983b50c8acf42c9822527413d866f3605812c3665263b0796e7a0c632464c131963eb1b39eb54b6d117fd25fede83fcfdd5bfa3edcee638196ae80213f7ede12505476e213d56a89224818d5764679824cfb61f0365494e5a4f26901aec701421c83145e75008a4472d66f06ebb3af51b886a2325ec482969d59a86dfadd0073596b1d47545699252a94475324ac07619c4b2664ee6f3b83dfe1c7ef6ece6a73f1040016d887adbe9d8e076de14815f8ad6bbf89d82b11c8de622d8094d122e7cc525f099280b1d3bf5557fae729b8b4287fa3f8a92623ac0cd62ab57a10f3fcab1493385a909c09e80ae7f7f0d8bc7459ee95930455412ddada18aa6bf8f8d98e5b7402605066cb4b6b5ff5cab305771cec6fd000032a289a8722d0a402afc66696798c6106be690d05ada3add2ce5947851c79dbfd323dfeb194718127e1ab87badfedea9cf54d4cccdbabf719b5d4af7f3697343b59974b5e263687aa60953df4a207784484d8867fef51eee561964b4b085ba35e822c22bf33e7fe10480f5a63666a5c654c350390521cc06b63b9b215ab1626ab9bbb8fccfd1076140cc362ef54ac515a2c924781db8d102e9b8b64149ebdd98bb102cbe31305ba0081c572ecc50846a7dd2e812f5965e3b12f571dc933b0bbc7492e8d4a593191e01ff9895d3807afded49d76ebc51e0b2b0072cf3baadea00568925088a1a6b1d5c659fda5a6a9af46329b067f5ef1f80b2b5ac6bce86c909f96801fae3f620b9435f49621a88f47da90ca9d7c8bb3d52a01897b92f78f60c64c9b7d9b2e7a1503a000fd00012328fc8b7f3b07c470ed2a03147afb9dc212ab82e70241794f999e711ecfdc51da0413b06167d6873a91a8335a5b2df0cf067681355bce28f19f5fe2a5c74dbc334d077ceca07c981b94c89f5b6aaa03b70fa4a2691be52d76461356320efb66d1d30a771132b6dba09c35de3ac8d46e9894a44a5e3757f17d2c218962af03aeec834380e2137479343535fd8fce9f21cdc55b3ba54a8830892a1afe865c39804811be558a668764eb6c206306132bbec65d21de4da92d9da947e28bf1fd72dbb246f3d5a32bdb7c2c4c6554c80513858b8b863f66f90df7b84299ab692b4f638beced0e40179b25ca27cadae375e6494298f1af1a9c6c8ca4c27012bd5a578dfd000100a3219c72f3226a3dddbd68ee35facb6b68f03dd8224e56e3f09fb6c1e8a5828471890b83eacb0acb587e1dcada3daa1bcd82e86588a3bc4258cad212f965ab7b7b67f1a201c3fb65fea00edb539524c439bd574a52795c55307dd5c69303e9acbdd82d227a866708e391d39f30a450876879e9e96e1166af40843d022a98b6fed4a3a75768ba478c8c51937b0feb9e714901bfb03625225c8f6079fefef5116d58da16284e292538496390666b292add9794937abb940b69efc3689de9a1d881128212f3da736d62a629ee1b5f0d0b9f14f68619352a190694a31d7cbead5c6e88e92882d58ea2b4b2d7fbd9a21abc42fa72b751c30f4d1dcd2b1d538a9ac580ad45380cf5586d1862e7b5cc1f5406e07ed7c9f8e2d60b51fe478c8cb1d0419eb74699935bda7fd39e1bdf588eada0a34c61b50952c015c3348b140b862124143c5a6bff8a95d55e96af5282d0d6e75de6b4d018cbe8c314e0e6ddc58b3a9769629e7b668119695c7454a83e476dcde3e823545911058b4a3c63e34d1045296dfd0001a2656e2e929437093f84eb1f5bb7128ec028b07421266121318b060f0de4cc8a979142cb6d18ea76af9e768249b046b79e6c134300c686c706266386af960f5139cf50208344458dedf8427753aa31c102e5a9c27fded58c1be68b6eeb1e7486bf7ae4089f189ab4b2f4cea1390749082ad37909c56bbd385a3d0ca67777c50c31b60a9055f4655ea679c3fe517e9c230a83beb74707d7d3237343972259908c0844814e73c838a8f476238a764c89fdb7dcb41ea693d81e4dd679f51cc7563e0b317336eadf517a3835e2d23ced4b898a4263a37ba4c40655240d10cdfbd608e59a960b3bad9f86945c4123ae65fee8cd0b11b8b3e4ce9186fe40a0d184a7b2814d798b5e305448d9efd326751ac2b5135e377165ba43f41dce4d20a469df4d161006f30ebdf5964229057ea556cc9c94da42385223d8d4a9a68c98452eb4eed7efc9ac9aec7e115e6f0ee4dd8a59f0c3af9025c27263a0493704548204fcc9d9100493fab6bef47752ed0c197c7efec06868d5be4c6a7085e44142d0681f9cc600fd00015fbdb2b09918819b56dda812b5213c0b2ad01cf668900c07402e80b28de1286dc3d073e305f0ab61ce03c3b5dc6661e8607985e0db2494ef615d15200cd441a409d6b579352cf40d8afcb386f2d2e6b193a1d4107ec1dc395c5fa542518356a5d2e60281396b45373a88655a899f0440964b6897e59bc6e4975ca80cfab2329bb4ab9e64d55146acbae756fa01de037ba67f1b32c1e3bbe3c897d442d91f2fcec723eb41ad81ce587f89362480642981290750dacf2d89ca70b317520c13d535438e7b3baab43f0f9470750b3bbae829dda95da000ecffe01a4c78d0c62c15218a88a31afc3ef3f8af914c5975cd9d279491c849265822ee1acc28384e5fe0bf8037b0d912da535403f5b695565c69b8b0354f5ef2a7984ede9b647416cdac8e9693dbb793a8dd01624515b5839d8c9df43185e5a534b02cbf5211a5a76901a5f290d235514f7ede4c384033c438aeb13e916b0b3808af47a069d98c7558bb411464d4f6f47b9a27b0c108ee0105accc00990c74dca25dc8670406c9515b6f381d819ee591e0d496a46333f86fee821a2a99c57f033ddf7f5a5ae2ccad12fd802e2466086269dcd30d5bf166d0e75e78ffdb17314b500eecced0eb89fca7b80e7b072c1995b83aed72d5b033c20b31eff559d64541d17a97875da9075fbc3f2ba535b2cef3b7162a395d150634cab96a316c7c53d4ea11314a33afe53001e41ee3c50081cbf279e81c999aeb762f1ac725354d6e4b1fb7c81630dbeff0b04745dc66616ae25a2454e0f0360bb18079c1c76ad458ae27b904e5b878056d0e66ff205eacbecf2abc3742639611e2b638500423dd50fbc25f9972c1e4e9c2d0e84cd048e6c7cb22b11b3a2ff56cea64b7bd061ecc395a4d56c24be981db2975c234be0b2f81008005b631efbb2fa8782f143b7de38c0de7fd23c704c474c76ae0d36e8995db201572252634de4493f1e88f9abe2635e004a84d27294256a6515b003869890d730d1b9201d75b3fcbdbe0732d4c14543d1be2ecfc827546b7be930d1028972715e99693f7daa4a8a240248d07912d9fdeb64ab0683ea51c2f4d3dcc6787bae732528012df83c70e6e3600f7e891987318ad29b9289ea6b2e2bd82ace318eaa84c2fd8a490b6d34bbe6a75655a09a29835a26a53cb859c9930793f1ec0e3b178c58bb860145c5291f5cbed4c3eb998ac512c603964527e1e5a0f5356ed48b3d0a73adec895df93a5ef9284231cfa621ff60b936bebf4aef2a744a0fb7d47ee5f43085e802f80d547703739c5c00e8dc4a72cf3995aab570f821f775544c3901f2dce970d1b4129e4c28b57e8ff266329e0a37b6931fde5e5c54922291fd305a948afbb18f9effd5d0f60a60d35979ea239b80e249ef70b39f36ee312a4167e0aec0aaf23bdfcd7c53d64249c6351a87f066f8f9845901b7f757a05e0a463133e4df6571981a2c26bd1cbcde2cd0690917112f976ecd8be98df9d99fe49f98a11b45f5eabacda1ab4451ef9cd3d1abde3ab3961a3ad111786470e3ae064b5d3b5a16576834cd64ecab08e6749aa502ff3b7057b7ff751c234ec9b08ea5e4e0b55604114ce837b6718e2ebdd73d1b4cdd2307ed0f1a6123f4e3433d3c3fac7f08a89975ee59d00fd00019612c87cccbc8ece8380fb088fe8852362b7702d24848e60213f33efe5ef71b6d76dd868998e2533cb85964221e103dad6049dcaea215a58610ea64eb88bcf4570a5cec9c88ceaf735f4d77d676dd6cd2abdc1c2a9d5e8d625927294a464fabc08ed5a769e640320af871b6c10c0b36ca09c398de5de8932120719d2e71c481536250d2eac410ab88c4970c68b996f15e4fcbd4090ff80f8677d55a8eb1274436ead2f15cd0f0a141d0706fcb3eab92392ccaaa36bd7f4eb816062bf3539a4b5483191667b1db7d13b5d043ff2c4a105cbf74a5f8450e16ef9e56c521865117cee88c49480177b5507d2916af69654cf760c5c5ec886878c27c2d3c8188072c3fd0001dacf4c63a866d5c2bd21ecd5441afaa3767666957dede948dec007df174f22cd53c05bc5cd2a36ca827fcb8446cbbed912759302cd005ee6a2c69454a3477db36bfac8c9cb9c1bfa913cf4c43f72ee57ad9a4c9c8f5551af8385d855226e2f18a55291dfcc1ae5d60b8a79d840cc539b926be1983c2f16067b104e2c63d8253646c89d31ab4c50f166105a04c19a10ff75cb467ee83717ed391c72464a7fb3772eecc36434bf946de0d03a4d4a5de6c4bcf8ef8643322167d73b424828c43a14dd635cd9db1313e4d4ce5254746fe90672606a0ae15ee5dd388732ccc0d3a4aa489480e6ddf7e6e0556d469166a96181cc439500ee7823f496be42c2950808a4810f484d78b6727e793fb2bd80a89e39bd21df6fb17584aeacb871ddc341cc6b7d10e0a476619f4a1380db2776c4a166acb9109a9a4e3e0d1c722b1cfefc135947ad866938137a8d8af919f4010e380f0d4bfd086059207e5a6155a66325355282a7453cc87c698fa58dc8c3575cd81649ba8ec17ac529ca74957e55b9f4bb56bf00815b9bd44f0df8280a75a1c07da606fbaaa180fd975cf0fb680960c3c3e6574f5db9ee72aca6974ab6cd40306bfbaef658a3613045af4e85edb81331919d3b9fbaf742fd7873c20c7ea1460abf68aeb1af6f3cd8efa6e8c072220c3c5aa34f650d1d946e400bc038be346c92d63a2160048aae8acfd9d14b707333ebe2264080b700fd0001d720e0212231342c3102b8025ed9be48f49d2e97999df5268fe485db4b86020262c62548456cea96a3aaae658ec4fd624269d96faa62933bf6da9f251553dc3085f61ef29fa80e09d651423fdb6feb6ec0fbd5a0e5cb370aec10f239d2858111df0053151f4697104af362d12224e069e0efe3d23f3580fa7dbdbbe2021882b7236e1cb6e589c30427f5afcb414b96b5223fef25d08efe1cc1b94727d05abd7b5fd828d8be4658e0f4467314f3623fbce8484fb58258fffbb0dcd775c78eca1ba6d0a125412db9915de16e6a7bfc80ce05108d8a1a29ffad260498f3d7d505a729b6d80f78cd7c84176c02fd37188433a8481b1ccd90239fd7e7041e2e9bacc880eb7e639dfa8733cb68a4cfabfbd894e153b67e939cdd425a17ef4cdb3c77b204dce1479bc7205b856461de37523dfbf9ea529fec53f78e737ae178bdfc1ccf9eebb12eb9fe4dba614698043db45384cc80ea339e16b8ddca87fb472f2a9ccb2b5152cf06306e3fa48824e5a3fd37e9552feeb586f426737baa0cd20b922235638096033eedc83083b149b489f7cc907ebbaf77e0a7145e5aabe02dc425aee3602ee5a8e33a0af6f84cf8acdc541c7a1626974b21a88088e9f42fa879852ecbdae5df0c7be841051f73c86d5e9af4296da7b84756afe65101ad68ce8150a4f44894c9c3883c7db6b9a8cd7080842e712d37180485ab416d4e4a229270337f4c68491f5457ec9b3b6d4ffdd935903f8e4c036db95e032911714af527430baf622582e1fd97f6581822672800bba9ea246ea960aa19bb71102cea154dc463f4a020561e2a40e835f9506e2c1bbd37b14bf169fee9e41d94d6481153999c6486b28361ac1db2896543a42d0e8d1feb8c9f038e3226b19daf72d454dfd6d9928beccc3b3132d1f4e996ca49f276000e9f48d9ef6726c487c0fb0ede75c69464278892803a74fa9ae56044cea55931ab6c214fe440522aa3770963ae910e8656eb8abe6d1ba0f0633644071a1cb7dc1111eeceaa16a2c3ddb66a555db4c412337dcb28895cee7cfc1dc8304a16fedf2f7de4fd1160d528087c2f55ea33a2c7feca95ca24dc71e1a6f0d978bf0d36bded077f5d55490ee150f2f83ed8009cbe76ecf7f922b047052e83da4706ffc1e2a4f19b3ce0f1c1ca1279bb60d8b94069cfb3fb5c328d63bd821e47866b6902a3c85a02cfe1be4664418a32eed422709c5a536648805b726b053da8269fa65e5945c3f0897566fff4a9aabb4df74fc48378fcaf9cc69d7cd820d0dd682d41af39663e3a12ceb63a4f5a875d2a1df3aa924270ae2d4640e53f8edaadb3f53a8a460dd5c7d3c5cb54cafba1911314d809091d593083dcf397a2e4b9258ced80169c0d7728b869be03b5882045a1afbb0275b1073bef509f2db72fe133df00fbb27ee2ef6ce6fd0e2608387175de108b1fb40b74746a337531375bee5563b9b2ee6b9d803be7bf52b7013f87bf4b7481641b2ab5479fe5bab4409858506ebd120c8350a53ac86e15c8c12485ab9f58c218c9e2f44a39633abcc333017635b19af3330dd4dc275e9848912363a85e7cb3e91ec588b171e936af4a63768edffd74fa05a2fa9e281cff6eb2d801b2fbb8e208dbabdffd387331b9239f53a80414cc223a6230ad96143e624f210d541de65584ebee32586c31be681dd2527d2a52576744ebeb91735f4ec6cb2f4bc8f94dc0f810fbab5e14304c370ea8fa4c208376712cfedf6dff2c4cea55968d3316d87cc68f0ea97eb59e79a5822b9e6e69020000000100f2052a010000001976a914b9e262e30df03e88ccea312652bc83ca7290c8fc88ac00000000", + "txid": "3d721fdce2855e2b4a54b74a26edd58a7262e1f195b5acaaae7832be6e0b3d32", + "hash": "3d721fdce2855e2b4a54b74a26edd58a7262e1f195b5acaaae7832be6e0b3d32", + "size": 23821, + "vsize": 23821, + "version": 1, + "locktime": 0, + "vin": [ + { + "txid": "0000000000000000000000000000000000000000000000000000000000000000", + "vout": 4294967295, + "scriptSig": { + "asm": "OP_ZEROCOINSPEND 23730 00000046551190596d29fb87ee282c1e2204bee5aeb7a1b1c1c28f1d507ca1b5d4f4a351f4af3663d653f8b1061fc77b2b7f OP_2SWAP OP_ZEROCOINMINT OP_ENDIF 4574007b360b3c59f2dddc39519ec1ab30bf290181d1dcd37f4a1e35a24d64937a05be7efbba8c418fe877092be132ec83c77c4098f059ddf947e1aec7e64022ac OP_ZEROCOINMINT OP_ROT OP_UNKNOWN OP_UNKNOWN OP_UNKNOWN OP_UNKNOWN OP_2MUL da3cb2b2e0105c555a26e42f89f842b219d60ef390a8e998967adf46f06900dd42059810b56112cb23660ed591f4de1eea034fe181a6b1 OP_SHA256 5e35212cbc3e0c3f29a138ff6aae9c91ea7abf4e20ce2dd27d7182696963ba53fa57d1eaceafbef2 OP_UNKNOWN OP_RIGHT b19b560a48cfee21fd69025902c23b8ea9fab931a60cf041c09418560020d47a746358826da947e16206a1d35d9879a9d785988bf300a1ee6641d12fea79a3991102d6d8f9b628e5402b0c357de333f9d752df7288ae0e8a60ab910694ee28a04889c52ab6eabc8b890c93fd8129d211357013ead3a8603be4843460cb25856936078045b5b07d1e2570fc2d0f45341827642c3a725a86e07352b2b8f52748e2be7adcfadde26eb9508a93fc5305551b9fda4fa819c1256d868c9b01857bc3a5ef1db57b6351557a53c1409425343abc40754cd121920eb99c92c711c730d838a129b801b2b152ff3b940c83c70addee716160951503eba21720f9859454cab7785cd7f25ecf3846cca6e6c92dd993268c268a3cd1f3d3c3818687f50f5423e658ebb7afdf3f6de96baf2e61b344103c2d16f20e31873d30b38e4a19856a8f510f98e74b819de5f2d208ede4bb3066e8a91d71f4a68f5901755a5faaf54a68316a09fd835f495018f2455f01b6470f8be72360d18baec83e89ed5064a87dd0cee41f57d09f87eecc3dc012f4d2d316544126959484d625a7922f288e1699a5b5b672c44cfaf1ceefd0b4683b1e7a62e9a33bf32412f1a49f1f8a0570dcfee53b9db948e35b9cd545e74e0d024ceb04bf726fe3c323ce002683447beb33788180dcad0a15569e968f185b907b24f0a91a00a237d92a5c2be6d752b27e06fe7238987cf7ee3ed0415a1cd0cc69b8eb586fd6f7b83e01692d9d28b59b9c98c231eb38165d42e62c10cbe4246bfba35cac79f0e002fda3b06941f4ebadba9109d81355ca6d9b0ec463ab4f41542b9cdacbc3c7303b66e5ce54fdb33f1a4e12d069a3154df189ce2f7340d95433de251da4ddf967e000fd69022b80e7bd4378a9be93d9558d63c8b2829c80e9ba75e4603bdcd45a9e100db330dd8017a00cf3d317c770b6d6dcb05cb2cace0e296ce2e8a96b71b0b6ea48be0e2e81cb66e76713a5877020a98acea1230eed97bf80b519b5dca15f724dfc754fd3150d2056ff113c9ffca161e13603f0acdb311614a44a47a2178f46a2017e73fba20d07a1da0a9792080875aafae252a7047154ad590aa34242cc5a76c2bb97c6e1f464d65abb5be84c64589496449f08d066267af9bd40ac5b7b55160f1d2f9933ceec99b3b5a4915776c7d1f5dc2d0226c0742e0c5376bc116aa571cbb692fe53e7bd9c05aa8160d8476d40f5208abf58bae2508bdc5e52ec25fb3a037d17a162646bcf82b6c2dd8560ed86c9a67668a8ade7cce1540d7742400e05d091058fd60396dbd0ac83b54134d64f76303f022da8765a67bd00a0d178a1e97dcf747551decbae17c89c2db17de96220a82f5364504ce7114794de930a35648fbcaeabaf06a329e8e0c3c87f2cae56134acdee0d86b3941d7846e6bbe424e89d8cff510057143547dff7c06ad7326d5bed5de75ec34b3163c3c58a96cca18afe399cef35341d588ff9c15c0c8f5a5a63727ee52311e3f28e3536292ddceb48018b6035113cbb3e838c668b2725f12978e5ab9d8f808dc64ccc0ca48a02c2344e8be8689740c60cd58159e45592c55da593f5f52b1d370a5d6fc364f03fc0ac094f528a67503cbb6fe49513db62596080b728be309f4ada27ead0923de2e89ff8ccea5a00c74f7d106928214e2feeb4ca2bc475cbf3bd7b3458f4d10db64c9abc350e244922519f2d13ddcbeea3f3b2e366eeb00d9d989142faf860823fb5fac1a3e0a72a102c69bfe4ff00fd68023299eb15b9c2892d691c8f439064db72f10d485fb32bc10bedf746bdd83e33f6a56978f66b0f89427a84ffb3f2521841d75a1ef262fbad0547a76deea1151a71b9a39f0d1c8df6c0fa6a66136daafe0b4a205f84df8edb19db8cc069aad6605178c7dd49e9e1af87de1b1ede3fd1ceea73f973ece91ad8ced139754cca4cffa5597bb9fab5fab3d836ee0e04c1ba1077500cf49543bbe5c986a8194b9cb5be63721c4d597c7082d456b23a20ad036c21f416b970a344305217f455925db751f52b0559bd986dd35192f639ee698c9468ba338a7e46ac9e50368eb86e5666af8431e7ae273e14d8202a557d93e3a93cbc1261a4bb13898c9fb15ceb3211f6f7d7adaa30b4baa6c4fea881b84c43f4ee2b9a9111a55fd502fefd95501dedffebebe4fca78fff7c6dd70e90adb7b8f2f611344791968aa3a0bfa06bc759721c622c8f2a4a67851c2acdd586952b84e287f086f60540934d05faf5a267f4ba3f6c17eb15c5fe6f302094247dc9c3d1d42a0017ac8e97400361c94f01c398ad4c9c3f88e21268203e3b52086d796a7147dd039329859e618f7054ca899219485c31bbf460a1b359df1c3a025bff338a365f33f48f71763647e48cc24472edb962d435afd64f394ddab6c6f64e6f54a3568f38ae45ce599fba9314f121eb1c6b8ad3e5964557a058186829a12002b2a9220a1ab55ff478562cb333ef6bb69d4ed4dffd9ebf39ca15f5eecde297afbfd7061e17eda335cf7212389abf1fc13053298cbfd6aa6402a323d5051947347e9fba76b059206a916a4ee84ff1f48c98d9be5ace61a2fef441c44587bae69770f69567ee8f52cd91adcc76250951be53462207cf27746c225e13c2164663cb0ace257902fd5815b878e4f19ff10499acd3700828a051f8c1ec33d421135089001547dc1df5cf9a43da6877472c6496ae65ec1e7b91bc3494769a03cfc6e350c588de0045bf26d0b418e08ffdae019bfb19f510e0e530d66f8173b13826b1281575a5aa703bb86cef598a99b9546e1a241fe86acc5a8f7156542fba23ff41c1db9267708f44dbce1f75465a7befa3e135393b1d5faae4f7d90c480656b0f012d1a66a03c76a58754b22e42f234de46e7f4f05192dc734f497d7d9a1989d657fd1bdb4e2379e4f576c5ee72be808dba602fd3501319e81fe1211176143ac5d9b76a06951a6a0413db2f4ae33d0f7d9a216fe8a5c5828c5af6778cae6464dea07262b1e64f18db9daf24fae038494836e7f96f8056a42f5966ac53f1e3bd7e2a39f129ded3d223908e64e020b7df2fdc275b993ac951921549d0b1cfe6464e8a3600f21714108f5c1aacdeaffd3416e28db6321b761f973ed338e95b559ae9ff6cfcd65e62d5e92b72cb244dda8ab5babaea6b992d7dc5ddb8bcfd189b2f564de4b57e03016f578c3d0adf004232f2f2ee155af2d6d0224799732c61513f10a51405be7b07ccce65f99f0eac9e3ae73a2782e34226508fee3c4effda657412c2bfeae4e4f2b63037db545bb7353b69654dab3f5da6e05e6c801828301e705eed65de092fc7081807643d9d3a84c2c0f00e460e4a7803f8fbc60c1803783f2a2c378e07531ce57bbb700fd3401139803deba8b83a31f7a90a52292c7b44d8c854a7dcdb835a2ee349fd4034792c0e62fe57a845f2927a74f363bf8f01a8a34266c8c3901c32b69f954e08e08e455f19775d92ee0114ead8da754f4403db89cdbf7e2a26d5560b060cfcfca049fc0b4b6a284f3c8b2ca99b0a53e1fbfffe5375cdb81242e758eb5fe13482030b78cf85d1dceb18833fd999d7f2b99a59961c12b8cd5e7cf8b0aa0212334023a28dd3a1211961fc7b7d8583a35d3a89b591e085eb2c63a111dd5ed4fa7b940733658a17e4ebdfb86a9132803d71a9a8b999fd9084a309214eaa5d12c6ade1d5afecf98cdb590d5d67ad79523ab29343643f9d6fe45afb34db61d0d7575f3fa21eac819d3663c5c868b32c0b5fee74ca11dc907de348029cc4f8b9db1008defc55f5f2f7f161d8249f5a5c4e7b643526f176d901a50fd3501be7ca3cbab1bfafd3e532d3cff08a4e43615ccfe9b5c75d661abb778188b62340f9a2f91c7b4e8f921f94fd023695364ce23a1a128cf630a36e69460c732cf514bb3a6512b23878d36505dae42b2680fb5bd293883938fc4964ce807d00a3d5b5bd93eb5328ba05c4ece7a62a6ce579ea0301c8cb04f359d93a68f4752de9641463fa9ae07d1b8ea2c21015539f5687be2977116e4ee99b1230ced94c52486e6ae38badebf88859df164e18ea343305d7153ebf5c6bb8fbbebf3c47cd23411961558edf12b57bf180819412bcc84fbc999fea2535efb01563c48313f12f3f42d3757c5da59e90948878b64f868be2604f8bccc4d103868ad3c9c346049a2c66c590067b890993f7de9b8b229cbe55b7d9c0d3716bb51c53188175fc7bc04bf4b744774ad7dce79d5bd21e4a4c294f8201c1c081602fd3501a925334ef2e47c0890a6a542f8321eef345b2cfd931a0c48c0296b20c1a22f741c3d7a133756ca24ca1455567fb99b6b6da19593a4dcdab7304b5963850e3b79442602217a64245cac37b1aea73afe494057b545324279d70041fe2977232b8a04ec926664ea4c10feb022da5e3ce3ec5a8725192c3d795a614dc479aa0c099f19d13bc97a30cf1ddb36182834deeb42e89b65a6b76cd00b934bd4bacbc9d7aeb0f544059f612d1c8837ebcfc2491fc5e9f1ae8a4b9f08d9877801b8f18c28da4bbcbbaeb8362fb18f6bec531557cdc5231f6ebd4fc73f97eaaeea338c62796b05e0b84b12c8c8de7b0444edd0420c2e5dfe1e6fc5a0c93b7e0ab7f005ae536e9b30a93679b9c5425aced70c1d60ac61d47705744e88b90697694a6b6f32a5eee6b60c4f96d0cfedb03ad96b8172aae6441e01c100a491037d637954ace3da0f416b9364be62df441262e33883df3ba56e9b6f665dbda14a45434e22edc692e0ef977f3d1f902084a3342833ac2ce396859131b64f0cd73bb1be3c22c99fc91dc3ffe07862cae7a34c4384d68d4f729b1b174d55b13e03dfa1fab5af8081d61291da97fd2a00762ae441ee631e242852bc20f5ed8b62a6e4725d977c66b16ebf4daa6511f7070e31b4446339c44d0a90dca22fb29085f2e02884fdd40110ab9262959ff2a85438df9126d869e3d4f7b85044344d4067c7af01979ffcb5598ff17cac8d6b588d9f82d87b8f144bd16149d9277ef00a79fa4d80ea97e7f7e7143246addf1e15e576789c0ad716c44f244d46a02110d413d456f8eb53da3d36589cf777172c14c5d3d56cb7d61471c0a6b22a6dd9f5928fa018ef0577c8dfd5cc5509da86e2a62cab87b5e757e0fbfde1cdf19edccc2d78636ae3ebacf75dbb1121c52ed86dda072db87ddfdabbcaf9b39fdf1fdc072af586e1a091fe00befb4572fac4c8fb4f9ff5f85c13f66f238f4f287c2e8e852729a1aab11188a942d8db8bb8e6483062c8e75166584e8ae11b6685026f8145951f6ac8ca9df676ce965c2f226e5d6c2cb482fd067f50030495d5826cf24d36516ca9894ad2303eda071956582eb6a60e6dbee56d472ec998b3dd3c5d08cf73ced73a7750c2936e23836f36e68544a3b7e02fc576de20e0a76fdb1c13fa6f4090bf91ace61373ccd5e573ee262daed75739f435121df7778313542421441c131cee9cc671fad72b2d1bd5748e6aed813e80f75ed6497522f75f1351ca859a922d1c122fcbd532c82d2a4853a1fb2ec698113421b5d6fc9dd429408c90051f8fab28f03cd7a86c61aefb1b1a833676a33df8ec52b3f697189db992758dfd580115f27596d43332bb625f4cfd5bd5e5545238aa31cc9b706d921f4d8b9184573b9249e3aa6d1d182d86c9a6de8f9b26b71d76d67cdd3638f2c48ade2b47dd60a95d119992c232a14ef05e053601c2a178647da59ad43eb5a4be732e1b8792d8a1d7d9259629ad7f882120b8f4f6984ab464183796bf5980d05bf32d85f61421ca4ff3dfd9c94c5dd3b1b33a0e3b113ab1dda8b2e6fe0daf32f72164a940c9dbbd9db8d460ea919e3f8338257f77ef3e884eb3254b5f60a92e0913d741acf9c173e92e3c0da33af70020649c004845c03018531c5394b3a53668b81eb539981c310270a3c7c4ec25567955eba73d9c37af67abab999f2bce0e14e19e835bda0cc7f5c58851fc4079f704ff8575d44e161f954e835e39ad1c5f9e2a414f890fbbdbfd1a50a1c73fd72ac36e4c2668ffbec8311c76a94340edca158d1acc2c0ea90042149a5b5d198081833bc3f1309fbb7cdf34de6e5dea2b04452f18f8714095ea9c9ab37aa003337a5c5c44a315d77ac8f7e35983106ac5ccee6c21534b87fcc7969e25caf720a6eb4b63cce609aaeec0dc0592340efb93ab426320bc035cfd5901f2ddb66c64b1198d80e619cc73ce127e86ddc9df078d3c71671333c7dad2f0089c65e83070efb0161a3014706337436131cc54e43f0e3484bf24661897bdfc34e64af6d49328f763c164c39e9041cdd3ddf43b1178869d9e4cdebd8e1592acd581a5402f3482c6ae63b34246592a35e9e220055f93c06f704b6484fb7f1b2eb0cc5e587cfa4d4dee683c3d412f4593873ba2191a218d5aadad29d7bea522307be7979158ab102f3e04329846f02793b775c271e7ab66c1d8582e53a2496a438188fde722c48e7f6bb6e91000b05c1553407622bfa2a9fb146dc169b163130baf7802ecbdf0bd059f32bd1a4549fefc9a3a03a99449c9cdbbd45206244fbd9792a69036e8eea32d82ac89694b65887a48308314c0efbf408c689d119ad46ed237c74c322407cc8d499c49bc454dd090802ffc33eff180ca0b3968b39e0df7f8b259cbe95b754ada17686e1530b0a702bca93b1ca42529d68000fd58013c59ca9ff207c4a2d57122e6c374b0c8125176b534bff226a91d7bbea935a07f8602c06eea81ed5ed388524c7a3fbe0dd4c850687652dae368a48bc8ce91711ced188b7da9a7ef1e7d8b96145b39faf8b2e95376cbd173bdeda632b792296dff0df80d4cb3e30fba1960cffb3492159938e0b61a632966284666f50223e3cd14bfb4cc1e95a707677d0ec770751860411b7fe90f4e2c078c11298ba2010c7410594b9de7e6fbe80aea2cb76f8be0c0572defb9d58cceb06dc1c84e197f867452e6a502bb7e0c18d5b1ec9004315563750ccefca4fb65aa1a51aa32773d6519281b7bf6ba826be6f5403b549c3e3646ddff159376c534fcc1e7e339af2ade2e992949d6f2d6362e1c26c70e60ae9669a3a73702afe1c06684794e75966612e9d99cbc7db18acb4a3f37baa1ede7bc419cf655499dac0d126ac3ba833e4aa4822c7bf2c49ed8d94b28055168f4ac738c042b6f21b4dd779539fdd4013688d933c2502cdaed2b4360fcef5c8173ef2c1f5a91604850ec2c81e706d1a2b0c87154380186b812304dcaa7363afe5cb6a52ed235690d746f1a070445fe4ab9a18df19f0d1e87b1a2e9bff724f6c77e2cbaac74a7694366f16620cf4a1d73e3fac311750c406c3fe6c5df4fa5d996d92673571550d694b47b69383e6251171010e3ac21f01f12fe2c764374a3457f34e83ec0c9e87f182f84bf72f1595714c8825a720545a865f223cc3863cb5631c8224bbbf3e082b2c07da33a0b180acb89db94127dbe3c060ef10a8b32298c153aafb1870464eba5414846330f5f274bb6b87e4a2613549853578b7024a249351fc54079737859c559ee066d6186ef6a06a94c19318ae8fd119998b8b8fba2990970a73ace570ae0dfd6a4976c7e240bc1224a410289793d0a97a71b6c60143b2f0163c69cdae4c7dacc707eec9d2de6820b47a6a900aec39f0157e729eece517ce5d1079f88811c6bd1647d32b1375eadd5bcd5b8ef6e9e05b79f4e9fb2497c2d0b1e886ef68b298af6421a7b527357a3cf8a10963d5503a0ed1355ad8e003abd987fb9fe9e26d919ffece2fd1f00fc87188e2a1fd0cfd122c58fab58ba37a61312c68f641908df7043b1b65fe52707eedce969a8a8dd245eb4694e9d01673b1e441d81609b0a91c4ae4f779c7b1838386632fcb1f1dc90d74a3920741c4c0c3ed4ca4b61a0b12195bc5e16f7ea637a38e63f52d0aeb3e4865d1650a2cebe2c14c5a4c2a155975755d0cdd2e65f9ea0dcbde187cad3a88544e0d9b4a4900a590d5a44ab0121ae1f4ac2eb65b5eda140899d5fa527deb95ca4176769f96a68ad3c506723860b0146eaa4360b738ceaf67292a88f4c15f5c91183fab11fa57427a87ccfb1b4214b44c0d2c6d9668e4abe6e4c43934934eb5c621d5b097508411896b343eab7a5acab87607386f907608f6bd3de45fa08183e01037f339cb3905fa8bdd791b8e7d9ee54fc2e424a1537f63e48ad2420d219b14c7025e7d32c0292867d30c023d3900e6aad9c768826c86467b1ebc2ef86774427eb433785f7b5d05db05b056195824d3e40bc2785e40250206fb1680814835100fe5a77ba4cc5816a80b1edd12ee960fc9fc898cc6051d625206d1663c4aad291b5a8b6f9aab95a0e60e9f12f36 OP_ADD OP_UNKNOWN OP_VERIFY 8 OP_UNKNOWN c5ec460d4a5121469a59ebc1b20742 OP_ZEROCOINSPEND 592976434be70e9406aa2900d31d637dc65fd2de61a80021c54f7dcf90aba4912a73a20038a951127348621ff65add2a75feea07162e63b1 0 ae0dc0278bcbb2968e8f6f2fa99216a614adcd38433b32b5481ff35082e6f19f00 60b1d489bb9b3ee9f5670890d8bf329bdb906955ca9c9b1e23190c4af9b92513 f59505121fcf1a53150766b2b65e55e2b36cc7fc61da94746b17a9b7f97df86e 76dcbe98ccffeac440de898fafa058b7501b07691431b6d32ada652102d55b28 974a8dbc563de8510d65da16fdf79575b59fd2a490177a7f5bd63ff03d48a554 1a31ebd30e8c013223a76725afe3d50caa5e1025925a4c03d19dffb17f5d1753 e1bf7f439ea8079322a86024e1253cd71604d458c67e09929fe89394402d1650 be0d25d6e004b1f86d249a8b4b9e06b5619d165c2057aec4c4bee1a0ee4eb240 7beb32c9e29f2dee1bab88aa620d7ed7a7dae80d04f03c1c17ca78e1a9c803b700 b7b27036b274dd398eacccf27a1f8d67fdb3bba2819c5ef0aa94b7c3995464a2 487edd3892385c68e0765cf86ac7379a6ba506c3d687615dfd1664a61e0df106 f6e44766be42266c3202569865c8341a8b4a9445769ba336cfacd7b8141f9a9a f1e28f7a220f0caf78a9ce7a4524d87fb1a8cdccfe6dec364d94ebbba6dd93b200 15b207a64913e0303ec3915a67279f85002410dc25184f06a03b9177f313469500 22c96e73a8fbe87b7755f8f2181f91b5d5348bc861fd6ab35ee71b4ddc5d8a1f 0837a3775e5e598150999a4706ec22526e8321f73f7e78d0693595aead84128900 9538d13c754a2ac0f1ebbc737d7bd3a4468b7e91636f10bbd980d8253ba5f3a700 182188329afd23ba2916e46880a016b493538ee3ffc4438488fcf6a36d78e48900 5add8ddabdab1dbbefb5c2439ff789e158197076ab6b8d99ab37ec4d23e0151f c876fb7a9976e7b0e8b4fa2d40a26a1f88b5203a992c71f86c863f64409a6c94 ba46a5a64b38094cce0e477fcf526a371f81d98758305173ff85e5af9e9d7135 a29a3995c535d10d2de254ce8dc6fdb52a0e6965d5faeec07548aa6b43a91159 7f1341316ff39e8dfaffd537063c130f3dd19770d2b911eb407f1c05b42e398e00 d2b3667ad2def5e59fc37b22e196fbda8d2c41b886be1f3cbef4ba78e7fb1b18 1d0e660c8294d3550ea90d2e976f0263209275ba6e277ccbec9daba6d361286c 28c77b1955f5cefdec1e35cc2e9121d07651200e90184d7cf32f40dc73432c41 7769836ae0d553d95a53b045352d122ac2c489cfb66a172346a3de53801ca99e00 94543d995a9f86fb5f49c78fa23d0868faeb3bcca002fd7604fbf81f38c44a71 37ffe7281b281b17aa5419276642b8e69eb1b1eabe30ebbefebb022c21f268a300 8092e548789ae3e160dbbcc8ad981f80804d9e485003a6c688fdecaeb277b500 2d5b57d5d18194fea324bb7c742151f84f9fa7fdb69fac77ed936a56c80cdb55 015264325a4159703b2d38af540c0e680ae700f3b9bf3c069a80696bd322a955 ac7f435a2907331d8dc15dd9dc945807e3ee5ab5295bd574483300431612edbc00 9836c6b63d43ee695f135717c85358663d39944bab412134cfd66db5762c9a44 45dbfb1f0d944e7f7b6d4b3648659b3b12a4a2c53bd72f9f65e7198957db9f8a00 f8fa17536fd1f70702678ded21a1c7035ca8f088961c04af7c7a4a5df96f0ea600 b7b386e088b3bbb85f1840ff606079ac9ea7f9d0beb62f5c7c5a924913df2c4a a977ef35ba6c8f89af4b16d5903a1f0d005982c2826797c6fddd0cd2bca1b94c 5c0b1932340551606bc9e2602bbfaf633de59ad8fcfe19c4050dac8c66493731 28b3e3013ab25c7815169231b9b724e8ae2ca3bdb5fd17487d1fa39046cb7748 53b98d7674de0cbba37c37751a7adfbc9c0cbf1b40752921a7d91b08e584fe35 5372487e10cc1f1e2d524bb76bc4422d97602f7893c62d28ddae4fc9a896d037 67c5cd6065fa02b76a852744f9cf0b97d32a14ae4cafc94a52087f726693e139 963c3293684a500a48267f5579e77eda8f877d15e4911936d0f8e74b4d38d98700 8be980fa8c412eedc13df4b6231e3d2b564296825f490db1e2eac607a3551137 397b07219a89c803defc3fc3ac5fc258c8b54b39f53184ee13242feb50a0c624 223706f83565ebce2acd2c18f4cfa79edaf67508da1d2472bfe325e5f20cde47 3dee7fcac92a23e8c1c1325e6f086d1c8cd27e47535899399c6e1e4f8784f0bb00 14a117fbf97976c0c7af3a56308a4dd19abf6f6a7afb4238e5cd2b41ff3d8b53 bd2e9d0964bab0a1e554eb0a1b350928f2810c4fcdab5ab4e875005cb4a9e69700 0e9fee09a4bce859bb38e62a7c74941cb0376d118f1738f06b8a517fb618ec7a f90381d08f1fa4eca24bccd2e979d0f28710375da371378f74f991439ae08132 0b7166584e050832e699ec020e5ae55f07fe8ae4ba7c2c399ef302fb1abf0643 eaf6573fcce33c66ea0aab58eef64a3efc1f637b738ee51a95b162eaa9cc476a e6540cd1e230afdb93aebba474c269c423facf47f2bd500e08961f7c0a4af553 a8fe890159adee60472ed604e73b725c36b2e0a1dc9dc94138a95ab43b381529 d80414b480db1b23a83530d76b6ba4768b612856f328c5d1f481c392bd69f670 5cbf3bf512e6647b24098affecb63045ba48ee161913cbea137d89f8c2317e18 3ca2d715f1dbf2f7d1cd1843584cee3c6cb663830c2566d2375a8b7d4306a7b300 67451e9fa32a3f67f8940b3d5ed7356e532ab64588a30bc64e68bf0f1754eb69 aab661ffb9e2489a080b5dadf8b66a01b4da585f1d60fb19803d7870aab59f9400 09a42d8c17bc201a7683473c104361db25afd272558b431c7205c1ad60e52757 b5259493fe51d34e9e9f13cd027324de99208f62fc7088503d065bbd22eb671e c2a1ce148baeb48bc4074806162c5081bbc4636a01d2947e2e511a8e23f05010 7f78c01cf3b1de88c2b5efe4f1a44da7b7ad1d70de3a9ee75de52c21f5d9dda100 b1e53880cf898cc3304af3330d0dc20424ccefb35751124b925e132d89ffbb8400 ddada2ffe00b2b281c447b8d03562bdfaad7248bfd3b82ac74178258c17f629700 9f39211210bbb04910304087e2907c3a8a12ed4142aaa866b6916b3f17d1c323 13567eae2f96882409da14e61072dc3941a7592b816b25b3d52f3ccf8ba5499500 7e8262ee95708b1b40ea9644b6307ff3886fa0159a3e28d6155e3c4f737e2a9000 8a5f96711ed5da026ea1c3e40ec96a8c5860e871ca599c9ea740e3ceaab24807 cb25ebb06c94ad22dc7d529f3296366d4f65781e165de8cab751d4fe464da972 269dcf06c230675b405eec3bd7b1e99d9191242bb0d8f089d31f5d41d61e4768 4635add2924737b775e5c252b8ec10a1d072ac4ab941d8745ace8db5ca7c72a400 25a5b0916a1b7e739c6926915cbdba2f4fa3b8b2728a7030cca4946362e3e84d c5b5d7283e047fd80f2281445463424ac2a6f1aaf2053bbc3c136254bbade218 OP_RESERVED OP_LEFT 49c2a7eeee02072a84d52810a6e308b5b895f082b83827d566722f f9dcadcc7437e6a5df1f12cbb56bf34473a0bbd93b18b130a8a3b98a08ff3212094ecf6309aab5bb96fc39e51df0828b70ed423b3ea325d175f412bea1f96c89ae4459987ad1 91d24e968ddfa4f4c00e2fd4ee2d08d2c0e6ad48129c32fa7bc99c3681e3f7996a1b93387a105209 c62c2c64a0ec1889c5eb5c1313291a78dd7213244c21eb9a9da1b77c9ea77880305bccd24ffbbad2883c52dc411485b64a291bcc1440f9eba8277d0d8db1ebc00f874f52b126e99fa1 OP_UNKNOWN OP_UNKNOWN 1 OP_DEPTH OP_ZEROCOINSPEND 5 16 OP_OR OP_MAX OP_RETURN 17955 OP_FROMALTSTACK OP_UNKNOWN OP_ZEROCOINSPEND 9e217afdd1de8a60be75e11faeda6091a37299745789b6ade6800081d69088cb8d7bb502ead5f1955391de9d7fdb577fb2da28195a81f6902612 6ac9f15ae160b2977310cee6660ecdac2fe9f801f9188635c83ae12a89e3aaab5ac05d3b988fb6854f17faa24d0dd9d29d OP_PICK 9ce3d453903f951a6c83bd4c5874482dc6b0e4883ffa65e4a955c45f7fe7ef32f5ec034595c8216cbc62393ea19900818b43280d3245ce70caa22225803eb986dc3353c37d798f84 OP_DUP f12a56e00ac6dcfb4350a8e6f108b0f10a1975d0e47508730903e94a2ee8 OP_UNKNOWN OP_UNKNOWN OP_VERIF OP_NOP OP_UNKNOWN OP_UNKNOWN 02bcc103367e15e325eec1cb09c86f40d632e9bbde8b2f6006b4981fed1772729c17d1cf3859e4cd OP_UNKNOWN OP_UNKNOWN 6ff6f6285450b520180f04665c25527cfc85da4596bf00804399c22b05bd36cc68e8e7b5 OP_ZEROCOINSPEND OP_VER 11 OP_UNKNOWN 06eed211d86887cd37742f1108acf1f06278eb9028eee4673e0cadd2a5e1f5f257422afb0fcc199e65728ccd12fe689ba03b50dde3957bb674b01baad178efa863bcd10de5235f3f OP_UNKNOWN OP_UNKNOWN 2933488e9b4a 16 OP_CHECKSEQUENCEVERIFY OP_UNKNOWN OP_2ROT OP_FROMALTSTACK 12 9648aeba59eb3e50ae3be842336355c36231630a918900fd0001c22157003bf3613cb4e60bc0842ef72d 8032963 OP_UNKNOWN 35f79e7975e6d93593c1727ece0f9734776e3fd8354869dd0c2e36d992e493524f97875a5798e45ad8800d288ff3c5ed1c656298547b3f386690d20d323d OP_HASH256 d684b557ffdc2fd64c2f3f71938ffad426211d4e0fa1ab71bf2eab2095a61868ad51bc622506f95d2186870b9fd55fadcab4734a96bb996948339408559f1ab3 OP_UNKNOWN OP_PICK 6ff3830c22dcf8387590bfee93005b5baf5890bf9e3c925d40906e714205aeddb42376eda4f4ac7d96bf9a74546ca377bece79b690d870a560c3b1 OP_UNKNOWN 6b06bcfba6904392ba19214fe91184b7545019fb8a5c65e0a6919720dd962c91f98992177eeaec4665b6fd000152c7953810a6139a35d9ab44951eacb6d7f88b6a 0fd1a05cf109d8f9b8092d1e970d6ef12cbfd2f8f901baae01d8830b8cd521e63300bbc1bc623fa5c0e4801733 631d42b0e71d1508e7b8dcc53fb304d4480e2a4e440c9e53204482c72d97b4d8561306d64030846c9027bf218567d607c4a2304df183036f1861 OP_UNKNOWN OP_UNKNOWN 42ba64961824b80fd8 OP_SHA256 499888f80a11cf91ad2fe187aae73605bff8a4b004a2738d56a5abf11f9b82f8ddd501443545bb4a OP_UNKNOWN fe39b64c7a768380892c6f00f8cfb49f4594e1c88ceec1125a3b70e890150dace647307c1cfe715642756d5d2f6c28218274ca5668a3c2a4af4b79e70af8b83de56337b841c94dcef0 OP_UNKNOWN OP_LESSTHAN OP_UNKNOWN 0 9 OP_UNKNOWN OP_UNKNOWN c59e7b389dd374956c37ab4b8978cf0aa5b7050dc50510f381eabac6fa2e91934b72e798eae1f6f43be168ce1ef600e7b9bd1bbc2c2c OP_DIV c1c777c41ea9ce7cb85dbb140bb01cd90bef6298783d8c6c056955eb83b7b9df63ba4b9cb4201cfc83897e54e269398be9aea1e293fe7131f92d22b1 OP_UNKNOWN OP_2DIV OP_UNKNOWN OP_GREATERTHANOREQUAL d934980f4f0e1b8a91dbfbec640010d91623780a2e7647391eadd5a10bedc3efbbdbf189c33057605f1cfee7 8ced664531535abace6d63bcbc OP_UNKNOWN 74d461c91e4c836a9534b35a735f211cfa324d74febc41fc9ea3e6e953ef555deb6ad348e35ef3 OP_UNKNOWN e32d2546006d43765fb7275d10c47618d109fe806a4f94fc67940ee02aabfd0001 OP_DROP OP_OR OP_UNKNOWN OP_UNKNOWN c1912e88aad895e87882da714c625143b5f1a9ecb9 OP_DUP [error]", + "hex": "c202b25c3200000046551190596d29fb87ee282c1e2204bee5aeb7a1b1c1c28f1d507ca1b5d4f4a351f4af3663d653f8b1061fc77b2b7f72c168414574007b360b3c59f2dddc39519ec1ab30bf290181d1dcd37f4a1e35a24d64937a05be7efbba8c418fe877092be132ec83c77c4098f059ddf947e1aec7e64022acc17bf8cfced88d37da3cb2b2e0105c555a26e42f89f842b219d60ef390a8e998967adf46f06900dd42059810b56112cb23660ed591f4de1eea034fe181a6b1a8285e35212cbc3e0c3f29a138ff6aae9c91ea7abf4e20ce2dd27d7182696963ba53fa57d1eaceafbef2cc814d0b17b19b560a48cfee21fd69025902c23b8ea9fab931a60cf041c09418560020d47a746358826da947e16206a1d35d9879a9d785988bf300a1ee6641d12fea79a3991102d6d8f9b628e5402b0c357de333f9d752df7288ae0e8a60ab910694ee28a04889c52ab6eabc8b890c93fd8129d211357013ead3a8603be4843460cb25856936078045b5b07d1e2570fc2d0f45341827642c3a725a86e07352b2b8f52748e2be7adcfadde26eb9508a93fc5305551b9fda4fa819c1256d868c9b01857bc3a5ef1db57b6351557a53c1409425343abc40754cd121920eb99c92c711c730d838a129b801b2b152ff3b940c83c70addee716160951503eba21720f9859454cab7785cd7f25ecf3846cca6e6c92dd993268c268a3cd1f3d3c3818687f50f5423e658ebb7afdf3f6de96baf2e61b344103c2d16f20e31873d30b38e4a19856a8f510f98e74b819de5f2d208ede4bb3066e8a91d71f4a68f5901755a5faaf54a68316a09fd835f495018f2455f01b6470f8be72360d18baec83e89ed5064a87dd0cee41f57d09f87eecc3dc012f4d2d316544126959484d625a7922f288e1699a5b5b672c44cfaf1ceefd0b4683b1e7a62e9a33bf32412f1a49f1f8a0570dcfee53b9db948e35b9cd545e74e0d024ceb04bf726fe3c323ce002683447beb33788180dcad0a15569e968f185b907b24f0a91a00a237d92a5c2be6d752b27e06fe7238987cf7ee3ed0415a1cd0cc69b8eb586fd6f7b83e01692d9d28b59b9c98c231eb38165d42e62c10cbe4246bfba35cac79f0e002fda3b06941f4ebadba9109d81355ca6d9b0ec463ab4f41542b9cdacbc3c7303b66e5ce54fdb33f1a4e12d069a3154df189ce2f7340d95433de251da4ddf967e000fd69022b80e7bd4378a9be93d9558d63c8b2829c80e9ba75e4603bdcd45a9e100db330dd8017a00cf3d317c770b6d6dcb05cb2cace0e296ce2e8a96b71b0b6ea48be0e2e81cb66e76713a5877020a98acea1230eed97bf80b519b5dca15f724dfc754fd3150d2056ff113c9ffca161e13603f0acdb311614a44a47a2178f46a2017e73fba20d07a1da0a9792080875aafae252a7047154ad590aa34242cc5a76c2bb97c6e1f464d65abb5be84c64589496449f08d066267af9bd40ac5b7b55160f1d2f9933ceec99b3b5a4915776c7d1f5dc2d0226c0742e0c5376bc116aa571cbb692fe53e7bd9c05aa8160d8476d40f5208abf58bae2508bdc5e52ec25fb3a037d17a162646bcf82b6c2dd8560ed86c9a67668a8ade7cce1540d7742400e05d091058fd60396dbd0ac83b54134d64f76303f022da8765a67bd00a0d178a1e97dcf747551decbae17c89c2db17de96220a82f5364504ce7114794de930a35648fbcaeabaf06a329e8e0c3c87f2cae56134acdee0d86b3941d7846e6bbe424e89d8cff510057143547dff7c06ad7326d5bed5de75ec34b3163c3c58a96cca18afe399cef35341d588ff9c15c0c8f5a5a63727ee52311e3f28e3536292ddceb48018b6035113cbb3e838c668b2725f12978e5ab9d8f808dc64ccc0ca48a02c2344e8be8689740c60cd58159e45592c55da593f5f52b1d370a5d6fc364f03fc0ac094f528a67503cbb6fe49513db62596080b728be309f4ada27ead0923de2e89ff8ccea5a00c74f7d106928214e2feeb4ca2bc475cbf3bd7b3458f4d10db64c9abc350e244922519f2d13ddcbeea3f3b2e366eeb00d9d989142faf860823fb5fac1a3e0a72a102c69bfe4ff00fd68023299eb15b9c2892d691c8f439064db72f10d485fb32bc10bedf746bdd83e33f6a56978f66b0f89427a84ffb3f2521841d75a1ef262fbad0547a76deea1151a71b9a39f0d1c8df6c0fa6a66136daafe0b4a205f84df8edb19db8cc069aad6605178c7dd49e9e1af87de1b1ede3fd1ceea73f973ece91ad8ced139754cca4cffa5597bb9fab5fab3d836ee0e04c1ba1077500cf49543bbe5c986a8194b9cb5be63721c4d597c7082d456b23a20ad036c21f416b970a344305217f455925db751f52b0559bd986dd35192f639ee698c9468ba338a7e46ac9e50368eb86e5666af8431e7ae273e14d8202a557d93e3a93cbc1261a4bb13898c9fb15ceb3211f6f7d7adaa30b4baa6c4fea881b84c43f4ee2b9a9111a55fd502fefd95501dedffebebe4fca78fff7c6dd70e90adb7b8f2f611344791968aa3a0bfa06bc759721c622c8f2a4a67851c2acdd586952b84e287f086f60540934d05faf5a267f4ba3f6c17eb15c5fe6f302094247dc9c3d1d42a0017ac8e97400361c94f01c398ad4c9c3f88e21268203e3b52086d796a7147dd039329859e618f7054ca899219485c31bbf460a1b359df1c3a025bff338a365f33f48f71763647e48cc24472edb962d435afd64f394ddab6c6f64e6f54a3568f38ae45ce599fba9314f121eb1c6b8ad3e5964557a058186829a12002b2a9220a1ab55ff478562cb333ef6bb69d4ed4dffd9ebf39ca15f5eecde297afbfd7061e17eda335cf7212389abf1fc13053298cbfd6aa6402a323d5051947347e9fba76b059206a916a4ee84ff1f48c98d9be5ace61a2fef441c44587bae69770f69567ee8f52cd91adcc76250951be53462207cf27746c225e13c2164663cb0ace257902fd5815b878e4f19ff10499acd3700828a051f8c1ec33d421135089001547dc1df5cf9a43da6877472c6496ae65ec1e7b91bc3494769a03cfc6e350c588de0045bf26d0b418e08ffdae019bfb19f510e0e530d66f8173b13826b1281575a5aa703bb86cef598a99b9546e1a241fe86acc5a8f7156542fba23ff41c1db9267708f44dbce1f75465a7befa3e135393b1d5faae4f7d90c480656b0f012d1a66a03c76a58754b22e42f234de46e7f4f05192dc734f497d7d9a1989d657fd1bdb4e2379e4f576c5ee72be808dba602fd3501319e81fe1211176143ac5d9b76a06951a6a0413db2f4ae33d0f7d9a216fe8a5c5828c5af6778cae6464dea07262b1e64f18db9daf24fae038494836e7f96f8056a42f5966ac53f1e3bd7e2a39f129ded3d223908e64e020b7df2fdc275b993ac951921549d0b1cfe6464e8a3600f21714108f5c1aacdeaffd3416e28db6321b761f973ed338e95b559ae9ff6cfcd65e62d5e92b72cb244dda8ab5babaea6b992d7dc5ddb8bcfd189b2f564de4b57e03016f578c3d0adf004232f2f2ee155af2d6d0224799732c61513f10a51405be7b07ccce65f99f0eac9e3ae73a2782e34226508fee3c4effda657412c2bfeae4e4f2b63037db545bb7353b69654dab3f5da6e05e6c801828301e705eed65de092fc7081807643d9d3a84c2c0f00e460e4a7803f8fbc60c1803783f2a2c378e07531ce57bbb700fd3401139803deba8b83a31f7a90a52292c7b44d8c854a7dcdb835a2ee349fd4034792c0e62fe57a845f2927a74f363bf8f01a8a34266c8c3901c32b69f954e08e08e455f19775d92ee0114ead8da754f4403db89cdbf7e2a26d5560b060cfcfca049fc0b4b6a284f3c8b2ca99b0a53e1fbfffe5375cdb81242e758eb5fe13482030b78cf85d1dceb18833fd999d7f2b99a59961c12b8cd5e7cf8b0aa0212334023a28dd3a1211961fc7b7d8583a35d3a89b591e085eb2c63a111dd5ed4fa7b940733658a17e4ebdfb86a9132803d71a9a8b999fd9084a309214eaa5d12c6ade1d5afecf98cdb590d5d67ad79523ab29343643f9d6fe45afb34db61d0d7575f3fa21eac819d3663c5c868b32c0b5fee74ca11dc907de348029cc4f8b9db1008defc55f5f2f7f161d8249f5a5c4e7b643526f176d901a50fd3501be7ca3cbab1bfafd3e532d3cff08a4e43615ccfe9b5c75d661abb778188b62340f9a2f91c7b4e8f921f94fd023695364ce23a1a128cf630a36e69460c732cf514bb3a6512b23878d36505dae42b2680fb5bd293883938fc4964ce807d00a3d5b5bd93eb5328ba05c4ece7a62a6ce579ea0301c8cb04f359d93a68f4752de9641463fa9ae07d1b8ea2c21015539f5687be2977116e4ee99b1230ced94c52486e6ae38badebf88859df164e18ea343305d7153ebf5c6bb8fbbebf3c47cd23411961558edf12b57bf180819412bcc84fbc999fea2535efb01563c48313f12f3f42d3757c5da59e90948878b64f868be2604f8bccc4d103868ad3c9c346049a2c66c590067b890993f7de9b8b229cbe55b7d9c0d3716bb51c53188175fc7bc04bf4b744774ad7dce79d5bd21e4a4c294f8201c1c081602fd3501a925334ef2e47c0890a6a542f8321eef345b2cfd931a0c48c0296b20c1a22f741c3d7a133756ca24ca1455567fb99b6b6da19593a4dcdab7304b5963850e3b79442602217a64245cac37b1aea73afe494057b545324279d70041fe2977232b8a04ec926664ea4c10feb022da5e3ce3ec5a8725192c3d795a614dc479aa0c099f19d13bc97a30cf1ddb36182834deeb42e89b65a6b76cd00b934bd4bacbc9d7aeb0f544059f612d1c8837ebcfc2491fc5e9f1ae8a4b9f08d9877801b8f18c28da4bbcbbaeb8362fb18f6bec531557cdc5231f6ebd4fc73f97eaaeea338c62796b05e0b84b12c8c8de7b0444edd0420c2e5dfe1e6fc5a0c93b7e0ab7f005ae536e9b30a93679b9c5425aced70c1d60ac61d47705744e88b90697694a6b6f32a5eee6b60c4f96d0cfedb03ad96b8172aae6441e01c100a491037d637954ace3da0f416b9364be62df441262e33883df3ba56e9b6f665dbda14a45434e22edc692e0ef977f3d1f902084a3342833ac2ce396859131b64f0cd73bb1be3c22c99fc91dc3ffe07862cae7a34c4384d68d4f729b1b174d55b13e03dfa1fab5af8081d61291da97fd2a00762ae441ee631e242852bc20f5ed8b62a6e4725d977c66b16ebf4daa6511f7070e31b4446339c44d0a90dca22fb29085f2e02884fdd40110ab9262959ff2a85438df9126d869e3d4f7b85044344d4067c7af01979ffcb5598ff17cac8d6b588d9f82d87b8f144bd16149d9277ef00a79fa4d80ea97e7f7e7143246addf1e15e576789c0ad716c44f244d46a02110d413d456f8eb53da3d36589cf777172c14c5d3d56cb7d61471c0a6b22a6dd9f5928fa018ef0577c8dfd5cc5509da86e2a62cab87b5e757e0fbfde1cdf19edccc2d78636ae3ebacf75dbb1121c52ed86dda072db87ddfdabbcaf9b39fdf1fdc072af586e1a091fe00befb4572fac4c8fb4f9ff5f85c13f66f238f4f287c2e8e852729a1aab11188a942d8db8bb8e6483062c8e75166584e8ae11b6685026f8145951f6ac8ca9df676ce965c2f226e5d6c2cb482fd067f50030495d5826cf24d36516ca9894ad2303eda071956582eb6a60e6dbee56d472ec998b3dd3c5d08cf73ced73a7750c2936e23836f36e68544a3b7e02fc576de20e0a76fdb1c13fa6f4090bf91ace61373ccd5e573ee262daed75739f435121df7778313542421441c131cee9cc671fad72b2d1bd5748e6aed813e80f75ed6497522f75f1351ca859a922d1c122fcbd532c82d2a4853a1fb2ec698113421b5d6fc9dd429408c90051f8fab28f03cd7a86c61aefb1b1a833676a33df8ec52b3f697189db992758dfd580115f27596d43332bb625f4cfd5bd5e5545238aa31cc9b706d921f4d8b9184573b9249e3aa6d1d182d86c9a6de8f9b26b71d76d67cdd3638f2c48ade2b47dd60a95d119992c232a14ef05e053601c2a178647da59ad43eb5a4be732e1b8792d8a1d7d9259629ad7f882120b8f4f6984ab464183796bf5980d05bf32d85f61421ca4ff3dfd9c94c5dd3b1b33a0e3b113ab1dda8b2e6fe0daf32f72164a940c9dbbd9db8d460ea919e3f8338257f77ef3e884eb3254b5f60a92e0913d741acf9c173e92e3c0da33af70020649c004845c03018531c5394b3a53668b81eb539981c310270a3c7c4ec25567955eba73d9c37af67abab999f2bce0e14e19e835bda0cc7f5c58851fc4079f704ff8575d44e161f954e835e39ad1c5f9e2a414f890fbbdbfd1a50a1c73fd72ac36e4c2668ffbec8311c76a94340edca158d1acc2c0ea90042149a5b5d198081833bc3f1309fbb7cdf34de6e5dea2b04452f18f8714095ea9c9ab37aa003337a5c5c44a315d77ac8f7e35983106ac5ccee6c21534b87fcc7969e25caf720a6eb4b63cce609aaeec0dc0592340efb93ab426320bc035cfd5901f2ddb66c64b1198d80e619cc73ce127e86ddc9df078d3c71671333c7dad2f0089c65e83070efb0161a3014706337436131cc54e43f0e3484bf24661897bdfc34e64af6d49328f763c164c39e9041cdd3ddf43b1178869d9e4cdebd8e1592acd581a5402f3482c6ae63b34246592a35e9e220055f93c06f704b6484fb7f1b2eb0cc5e587cfa4d4dee683c3d412f4593873ba2191a218d5aadad29d7bea522307be7979158ab102f3e04329846f02793b775c271e7ab66c1d8582e53a2496a438188fde722c48e7f6bb6e91000b05c1553407622bfa2a9fb146dc169b163130baf7802ecbdf0bd059f32bd1a4549fefc9a3a03a99449c9cdbbd45206244fbd9792a69036e8eea32d82ac89694b65887a48308314c0efbf408c689d119ad46ed237c74c322407cc8d499c49bc454dd090802ffc33eff180ca0b3968b39e0df7f8b259cbe95b754ada17686e1530b0a702bca93b1ca42529d68000fd58013c59ca9ff207c4a2d57122e6c374b0c8125176b534bff226a91d7bbea935a07f8602c06eea81ed5ed388524c7a3fbe0dd4c850687652dae368a48bc8ce91711ced188b7da9a7ef1e7d8b96145b39faf8b2e95376cbd173bdeda632b792296dff0df80d4cb3e30fba1960cffb3492159938e0b61a632966284666f50223e3cd14bfb4cc1e95a707677d0ec770751860411b7fe90f4e2c078c11298ba2010c7410594b9de7e6fbe80aea2cb76f8be0c0572defb9d58cceb06dc1c84e197f867452e6a502bb7e0c18d5b1ec9004315563750ccefca4fb65aa1a51aa32773d6519281b7bf6ba826be6f5403b549c3e3646ddff159376c534fcc1e7e339af2ade2e992949d6f2d6362e1c26c70e60ae9669a3a73702afe1c06684794e75966612e9d99cbc7db18acb4a3f37baa1ede7bc419cf655499dac0d126ac3ba833e4aa4822c7bf2c49ed8d94b28055168f4ac738c042b6f21b4dd779539fdd4013688d933c2502cdaed2b4360fcef5c8173ef2c1f5a91604850ec2c81e706d1a2b0c87154380186b812304dcaa7363afe5cb6a52ed235690d746f1a070445fe4ab9a18df19f0d1e87b1a2e9bff724f6c77e2cbaac74a7694366f16620cf4a1d73e3fac311750c406c3fe6c5df4fa5d996d92673571550d694b47b69383e6251171010e3ac21f01f12fe2c764374a3457f34e83ec0c9e87f182f84bf72f1595714c8825a720545a865f223cc3863cb5631c8224bbbf3e082b2c07da33a0b180acb89db94127dbe3c060ef10a8b32298c153aafb1870464eba5414846330f5f274bb6b87e4a2613549853578b7024a249351fc54079737859c559ee066d6186ef6a06a94c19318ae8fd119998b8b8fba2990970a73ace570ae0dfd6a4976c7e240bc1224a410289793d0a97a71b6c60143b2f0163c69cdae4c7dacc707eec9d2de6820b47a6a900aec39f0157e729eece517ce5d1079f88811c6bd1647d32b1375eadd5bcd5b8ef6e9e05b79f4e9fb2497c2d0b1e886ef68b298af6421a7b527357a3cf8a10963d5503a0ed1355ad8e003abd987fb9fe9e26d919ffece2fd1f00fc87188e2a1fd0cfd122c58fab58ba37a61312c68f641908df7043b1b65fe52707eedce969a8a8dd245eb4694e9d01673b1e441d81609b0a91c4ae4f779c7b1838386632fcb1f1dc90d74a3920741c4c0c3ed4ca4b61a0b12195bc5e16f7ea637a38e63f52d0aeb3e4865d1650a2cebe2c14c5a4c2a155975755d0cdd2e65f9ea0dcbde187cad3a88544e0d9b4a4900a590d5a44ab0121ae1f4ac2eb65b5eda140899d5fa527deb95ca4176769f96a68ad3c506723860b0146eaa4360b738ceaf67292a88f4c15f5c91183fab11fa57427a87ccfb1b4214b44c0d2c6d9668e4abe6e4c43934934eb5c621d5b097508411896b343eab7a5acab87607386f907608f6bd3de45fa08183e01037f339cb3905fa8bdd791b8e7d9ee54fc2e424a1537f63e48ad2420d219b14c7025e7d32c0292867d30c023d3900e6aad9c768826c86467b1ebc2ef86774427eb433785f7b5d05db05b056195824d3e40bc2785e40250206fb1680814835100fe5a77ba4cc5816a80b1edd12ee960fc9fc898cc6051d625206d1663c4aad291b5a8b6f9aab95a0e60e9f12f3693f46958ef0fc5ec460d4a5121469a59ebc1b20742c238592976434be70e9406aa2900d31d637dc65fd2de61a80021c54f7dcf90aba4912a73a20038a951127348621ff65add2a75feea07162e63b10021ae0dc0278bcbb2968e8f6f2fa99216a614adcd38433b32b5481ff35082e6f19f002060b1d489bb9b3ee9f5670890d8bf329bdb906955ca9c9b1e23190c4af9b9251320f59505121fcf1a53150766b2b65e55e2b36cc7fc61da94746b17a9b7f97df86e2076dcbe98ccffeac440de898fafa058b7501b07691431b6d32ada652102d55b2820974a8dbc563de8510d65da16fdf79575b59fd2a490177a7f5bd63ff03d48a554201a31ebd30e8c013223a76725afe3d50caa5e1025925a4c03d19dffb17f5d175320e1bf7f439ea8079322a86024e1253cd71604d458c67e09929fe89394402d165020be0d25d6e004b1f86d249a8b4b9e06b5619d165c2057aec4c4bee1a0ee4eb240217beb32c9e29f2dee1bab88aa620d7ed7a7dae80d04f03c1c17ca78e1a9c803b70020b7b27036b274dd398eacccf27a1f8d67fdb3bba2819c5ef0aa94b7c3995464a220487edd3892385c68e0765cf86ac7379a6ba506c3d687615dfd1664a61e0df10620f6e44766be42266c3202569865c8341a8b4a9445769ba336cfacd7b8141f9a9a21f1e28f7a220f0caf78a9ce7a4524d87fb1a8cdccfe6dec364d94ebbba6dd93b2002115b207a64913e0303ec3915a67279f85002410dc25184f06a03b9177f3134695002022c96e73a8fbe87b7755f8f2181f91b5d5348bc861fd6ab35ee71b4ddc5d8a1f210837a3775e5e598150999a4706ec22526e8321f73f7e78d0693595aead84128900219538d13c754a2ac0f1ebbc737d7bd3a4468b7e91636f10bbd980d8253ba5f3a70021182188329afd23ba2916e46880a016b493538ee3ffc4438488fcf6a36d78e48900205add8ddabdab1dbbefb5c2439ff789e158197076ab6b8d99ab37ec4d23e0151f20c876fb7a9976e7b0e8b4fa2d40a26a1f88b5203a992c71f86c863f64409a6c9420ba46a5a64b38094cce0e477fcf526a371f81d98758305173ff85e5af9e9d713520a29a3995c535d10d2de254ce8dc6fdb52a0e6965d5faeec07548aa6b43a91159217f1341316ff39e8dfaffd537063c130f3dd19770d2b911eb407f1c05b42e398e0020d2b3667ad2def5e59fc37b22e196fbda8d2c41b886be1f3cbef4ba78e7fb1b18201d0e660c8294d3550ea90d2e976f0263209275ba6e277ccbec9daba6d361286c2028c77b1955f5cefdec1e35cc2e9121d07651200e90184d7cf32f40dc73432c41217769836ae0d553d95a53b045352d122ac2c489cfb66a172346a3de53801ca99e002094543d995a9f86fb5f49c78fa23d0868faeb3bcca002fd7604fbf81f38c44a712137ffe7281b281b17aa5419276642b8e69eb1b1eabe30ebbefebb022c21f268a300208092e548789ae3e160dbbcc8ad981f80804d9e485003a6c688fdecaeb277b500202d5b57d5d18194fea324bb7c742151f84f9fa7fdb69fac77ed936a56c80cdb5520015264325a4159703b2d38af540c0e680ae700f3b9bf3c069a80696bd322a95521ac7f435a2907331d8dc15dd9dc945807e3ee5ab5295bd574483300431612edbc00209836c6b63d43ee695f135717c85358663d39944bab412134cfd66db5762c9a442145dbfb1f0d944e7f7b6d4b3648659b3b12a4a2c53bd72f9f65e7198957db9f8a0021f8fa17536fd1f70702678ded21a1c7035ca8f088961c04af7c7a4a5df96f0ea60020b7b386e088b3bbb85f1840ff606079ac9ea7f9d0beb62f5c7c5a924913df2c4a20a977ef35ba6c8f89af4b16d5903a1f0d005982c2826797c6fddd0cd2bca1b94c205c0b1932340551606bc9e2602bbfaf633de59ad8fcfe19c4050dac8c664937312028b3e3013ab25c7815169231b9b724e8ae2ca3bdb5fd17487d1fa39046cb77482053b98d7674de0cbba37c37751a7adfbc9c0cbf1b40752921a7d91b08e584fe35205372487e10cc1f1e2d524bb76bc4422d97602f7893c62d28ddae4fc9a896d0372067c5cd6065fa02b76a852744f9cf0b97d32a14ae4cafc94a52087f726693e13921963c3293684a500a48267f5579e77eda8f877d15e4911936d0f8e74b4d38d98700208be980fa8c412eedc13df4b6231e3d2b564296825f490db1e2eac607a355113720397b07219a89c803defc3fc3ac5fc258c8b54b39f53184ee13242feb50a0c62420223706f83565ebce2acd2c18f4cfa79edaf67508da1d2472bfe325e5f20cde47213dee7fcac92a23e8c1c1325e6f086d1c8cd27e47535899399c6e1e4f8784f0bb002014a117fbf97976c0c7af3a56308a4dd19abf6f6a7afb4238e5cd2b41ff3d8b5321bd2e9d0964bab0a1e554eb0a1b350928f2810c4fcdab5ab4e875005cb4a9e69700200e9fee09a4bce859bb38e62a7c74941cb0376d118f1738f06b8a517fb618ec7a20f90381d08f1fa4eca24bccd2e979d0f28710375da371378f74f991439ae08132200b7166584e050832e699ec020e5ae55f07fe8ae4ba7c2c399ef302fb1abf064320eaf6573fcce33c66ea0aab58eef64a3efc1f637b738ee51a95b162eaa9cc476a20e6540cd1e230afdb93aebba474c269c423facf47f2bd500e08961f7c0a4af55320a8fe890159adee60472ed604e73b725c36b2e0a1dc9dc94138a95ab43b38152920d80414b480db1b23a83530d76b6ba4768b612856f328c5d1f481c392bd69f670205cbf3bf512e6647b24098affecb63045ba48ee161913cbea137d89f8c2317e18213ca2d715f1dbf2f7d1cd1843584cee3c6cb663830c2566d2375a8b7d4306a7b3002067451e9fa32a3f67f8940b3d5ed7356e532ab64588a30bc64e68bf0f1754eb6921aab661ffb9e2489a080b5dadf8b66a01b4da585f1d60fb19803d7870aab59f94002009a42d8c17bc201a7683473c104361db25afd272558b431c7205c1ad60e5275720b5259493fe51d34e9e9f13cd027324de99208f62fc7088503d065bbd22eb671e20c2a1ce148baeb48bc4074806162c5081bbc4636a01d2947e2e511a8e23f05010217f78c01cf3b1de88c2b5efe4f1a44da7b7ad1d70de3a9ee75de52c21f5d9dda10021b1e53880cf898cc3304af3330d0dc20424ccefb35751124b925e132d89ffbb840021ddada2ffe00b2b281c447b8d03562bdfaad7248bfd3b82ac74178258c17f629700209f39211210bbb04910304087e2907c3a8a12ed4142aaa866b6916b3f17d1c3232113567eae2f96882409da14e61072dc3941a7592b816b25b3d52f3ccf8ba5499500217e8262ee95708b1b40ea9644b6307ff3886fa0159a3e28d6155e3c4f737e2a9000208a5f96711ed5da026ea1c3e40ec96a8c5860e871ca599c9ea740e3ceaab2480720cb25ebb06c94ad22dc7d529f3296366d4f65781e165de8cab751d4fe464da97220269dcf06c230675b405eec3bd7b1e99d9191242bb0d8f089d31f5d41d61e4768214635add2924737b775e5c252b8ec10a1d072ac4ab941d8745ace8db5ca7c72a4002025a5b0916a1b7e739c6926915cbdba2f4fa3b8b2728a7030cca4946362e3e84d20c5b5d7283e047fd80f2281445463424ac2a6f1aaf2053bbc3c136254bbade21850801b49c2a7eeee02072a84d52810a6e308b5b895f082b83827d566722f46f9dcadcc7437e6a5df1f12cbb56bf34473a0bbd93b18b130a8a3b98a08ff3212094ecf6309aab5bb96fc39e51df0828b70ed423b3ea325d175f412bea1f96c89ae4459987ad12891d24e968ddfa4f4c00e2fd4ee2d08d2c0e6ad48129c32fa7bc99c3681e3f7996a1b93387a10520949c62c2c64a0ec1889c5eb5c1313291a78dd7213244c21eb9a9da1b77c9ea77880305bccd24ffbbad2883c52dc411485b64a291bcc1440f9eba8277d0d8db1ebc00f874f52b126e99fa1d1ea5174c2556085a46a0223466cdbc23a9e217afdd1de8a60be75e11faeda6091a37299745789b6ade6800081d69088cb8d7bb502ead5f1955391de9d7fdb577fb2da28195a81f6902612316ac9f15ae160b2977310cee6660ecdac2fe9f801f9188635c83ae12a89e3aaab5ac05d3b988fb6854f17faa24d0dd9d29d79489ce3d453903f951a6c83bd4c5874482dc6b0e4883ffa65e4a955c45f7fe7ef32f5ec034595c8216cbc62393ea19900818b43280d3245ce70caa22225803eb986dc3353c37d798f84761ef12a56e00ac6dcfb4350a8e6f108b0f10a1975d0e47508730903e94a2ee8d9f36561d1fd2802bcc103367e15e325eec1cb09c86f40d632e9bbde8b2f6006b4981fed1772729c17d1cf3859e4cdefe9246ff6f6285450b520180f04665c25527cfc85da4596bf00804399c22b05bd36cc68e8e7b5c2625bc34806eed211d86887cd37742f1108acf1f06278eb9028eee4673e0cadd2a5e1f5f257422afb0fcc199e65728ccd12fe689ba03b50dde3957bb674b01baad178efa863bcd10de5235f3fbac3062933488e9b4a60b2cb716c5c2a9648aeba59eb3e50ae3be842336355c36231630a918900fd0001c22157003bf3613cb4e60bc0842ef72d03c3927ace3e35f79e7975e6d93593c1727ece0f9734776e3fd8354869dd0c2e36d992e493524f97875a5798e45ad8800d288ff3c5ed1c656298547b3f386690d20d323daa40d684b557ffdc2fd64c2f3f71938ffad426211d4e0fa1ab71bf2eab2095a61868ad51bc622506f95d2186870b9fd55fadcab4734a96bb996948339408559f1ab3d0793b6ff3830c22dcf8387590bfee93005b5baf5890bf9e3c925d40906e714205aeddb42376eda4f4ac7d96bf9a74546ca377bece79b690d870a560c3b1c4416b06bcfba6904392ba19214fe91184b7545019fb8a5c65e0a6919720dd962c91f98992177eeaec4665b6fd000152c7953810a6139a35d9ab44951eacb6d7f88b6a2d0fd1a05cf109d8f9b8092d1e970d6ef12cbfd2f8f901baae01d8830b8cd521e63300bbc1bc623fa5c0e48017333a631d42b0e71d1508e7b8dcc53fb304d4480e2a4e440c9e53204482c72d97b4d8561306d64030846c9027bf218567d607c4a2304df183036f1861fed60942ba64961824b80fd8a828499888f80a11cf91ad2fe187aae73605bff8a4b004a2738d56a5abf11f9b82f8ddd501443545bb4aeb49fe39b64c7a768380892c6f00f8cfb49f4594e1c88ceec1125a3b70e890150dace647307c1cfe715642756d5d2f6c28218274ca5668a3c2a4af4b79e70af8b83de56337b841c94dcef0c89ffd000109c0d936c59e7b389dd374956c37ab4b8978cf0aa5b7050dc50510f381eabac6fa2e91934b72e798eae1f6f43be168ce1ef600e7b9bd1bbc2c2c963cc1c777c41ea9ce7cb85dbb140bb01cd90bef6298783d8c6c056955eb83b7b9df63ba4b9cb4201cfc83897e54e269398be9aea1e293fe7131f92d22b1fe8ecaa22cd934980f4f0e1b8a91dbfbec640010d91623780a2e7647391eadd5a10bedc3efbbdbf189c33057605f1cfee70d8ced664531535abace6d63bcbcd12774d461c91e4c836a9534b35a735f211cfa324d74febc41fc9ea3e6e953ef555deb6ad348e35ef3be21e32d2546006d43765fb7275d10c47618d109fe806a4f94fc67940ee02aabfd00017585f2e215c1912e88aad895e87882da714c625143b5f1a9ecb9764ef1e1a1e654c08c70a2208e371f9c4b2aca734bca273072eb9cf5621c73ed442efc85624c10b0564c96f488cd5ed697fb7ea414c8f49f5668c4b41227cd57df071e004675cfe16914c9e3e018ac7e0b720b9cb9496f2d0e176ab2d611ede6e80ef5803566bf698e09b80a81c0eebe58ecda39093f0c1651fff5aff860c4b2e70460bb95da3a74cae7e26139d1b257ed9aae65dd4d86e240f07ea77f1691be722bf9855ffa759afac8a1e6c91326da71a1120092a914507c2000a167966a74c8e5fa8533078be90087d59fa75405168d72126667458525b6406849bc1bc9a97db49a37d084fd000154e8d56136b6dab9d5fadb3065668dab000eda7d2a8c47b342ee5c95281fa8e2fcebcad5a0943f2ddbc46390eac974b4b27ab9da4fb3747917c22305d3ad91b5694b312dfeb392b55df60cc8d4f6950bfbf4d5dbccee860d9997d2de34bba2335733909110bb273c2e36c15315fb79a93d1bffe33c358e2da4c238e8ae734fda09936a758f0713f720bc556381e41f76c29b7a02bd44926d5b2a7d818c788315c253a90a03b9194fbd581603e03a34bb298d8a6f4021b887ce813f3cccc17a2a7f6bdc5b50a681723890250c4e9050694c9a66fc587187973f209c1962bbc5bc7ff64fed7d6a171981b814a80a1cf3123a8dc622008cfac1baebce0dfbe52ab080209b50f0445fcf9488fe4818e96d8556331f20c211c60e07f1f80e3ab23103281a07c5df8d85c6fa1767aa997ab2dc3bbbf1533ffa8729bc02f6ed3d9ec12441576ea311a1e30af774c92f70f5d4521b1a67d0b7c1571c45e23785e70bcbbae1da98f8e2eeccbc67ec771a30b68e37f8a385820bfdfc7af405bd5375df20557cfd000167f18545407c8f34edfe760a91ca58479b4caaa3964af57568e4fe511cdc94e99919ac76e43c423dd4024457896c2367cb62da0ebe7d8b98cf79981256d870421ef6adafa61bdd61a9fa752a3102bdbe90ec1f9ea1402c855c2a78c5c09ee8a4297dd815aba0b346eb3be92a04301c33c83b0d02ea26a4eebbfba0b71667354bd8e6c825eda303b05207062b3b909397026f469a3dba5dbb851bd28500322b2b898efba194e9c89a97e378691c6c3587f7fe4bf1a3c69d31fb9195ea9ad626406f33bb39e8083452035038c1714d2753ffd79f62643057bff804d7693e014a80a9e32d1db6e9219e55ae5d59ca7f9615b252132a559ae8f0ea9bb70947170fd3806fe1ed3b59b8df259900cb793dd2f745658cb2cae325e988ae4259bf3674b40d952737b874531487ad58a38fd6d01435d4e14a87b0fef4ba40a2c985cc62ea2d3b6c97453c8bfc61ded2026a403939615b94db3ed5388adf92480ebe647d9c209541b9ab97a67f8afa8ba2ddc4f6621eac975806f7a2935a4754ca1281407254fd0001ca584567228a05aff314a6bb8db5d77c64cdc98049e4fc4e8a0dd02e94d65e494a83fd0573bf26071fdfce8455a8586bedcc9ec3912fb93c28c97c76fd2bd8cf7c77eb032f1b3d5f18cacb4d6e46d1d636e5423de333171621ac4ddf00cd140c8a31cfe6e1720b702f5977426ba0f341c5c121fa41e5f9cf72c676d7d8840760047baeef41a85ee0f58650fffa0dfcf4a354b4fd635f65d533afdf68682c062fa1ef3ed0345e0e6a4a03b2dd3fb6c1918fd4c6ea2e88efc1223bf72d33a12ec9f10212abd8e0d323fefe127edc909daf018a59e7be84b92ad9506be6cc080fdaccba9d0e6153e49ebe546afa2c3a5fd37294b035eaeb5a46ffb1020a5fe683b0810df474b799476566c1a4287bbd112cf2fcedd1be2cdb8707c55db9af086106f66a061f2f39e2ea3bfd1cbc18dcc049d9011336b2bcc240f731b3f45955e15d228656e7d41424ed16096607d48dfe0e2456d877645b5ea8f006b797958aad495cf7d57408484038b87ec99653b7fdc5b8a1ebf47b7883218bb9cd52eb8a22e49300fd0001310d818741b56832a1a31e3aecde85d578e6bef95e0d3321278f243dcf81ec7e2e6780e1bd4de223b5835f57184ea2c2edab2b870fb13f620b3124f2fc83740c26aa30b917680eb4a61a3d1e455928a6325ca2c330a74f35c659dab9219fc1ad2dc4fac28a8055bbc1acf272e294b21d1c3083c105b107e9ddd14314926a5067dfad3ea37c54ed50a5ac96391dea5fb553fa689d4166b8547b0af7764d22b31deceb9d8b25bd2edda13de0b952e8c062504896af885bd026edb9708bdb23617f0fc68a726432ea1c929262c82bb2be5f1536c6f88d33b308f8c929560caaf74b8fe5f840706e3e0b81bee0e46cdb134867bf8b11655fa204759bdc88d492eeb18093dce3035bac48a26fee7f6f5ac66adf63876ef22300572f528d5f482480e951befca94ed142ce71d311a3000d7895f2d9f688edd34ca44a68a09cfc4b685ef9f5e8a6d75e956e99a6bd01bdc002c94cf87861518df3a5a0713d7ac072254ac1d68de90d6a521348969bc2d59fbbcdef6918c045701421c92ef733b3b7c4a3678026ef6ecbb093dd60690ab9b7d28280a81598793788de0272eb52423c3b5335c844fae3a69374757a3b41e3cb2250e36d5e185eb2e67950782ecc1d31398965fe54f680c52b1806bd764fa2926377fa6f7f909ade7774b8a91a65049bb6862048d389c3536be88e1800ce95c0ef3477fcb5317ac511b78dde2dee12fe305773188132574bb60a5e68118e2373411b35dd42ca882b7b833c4efc20f1f3bab6cc6ff7036d48b2051bae2ac95dda94cc330ec1d0e3e09f856c7e36c44020b01a5076268aa5ac517cb4c9e936f958b7ac6f8fd67e961e083487b2befc7f923c559f6c52309b677fb090a604a6e9454c2461b2fa1574403fc2438fdaa1318c606707c0c600fd000124940c5f2606ccd649a9988afb6775e60891f95b91773924d7017af430cafcbd0484929b049c7a8372852bb695ce1748bdfbc150a5ca6a1519c06e5982c990e6b22f509a606337a647d9d1643522264b5838390e716cc8bd4f47bb8a0de23577b998855752c434efe432595f63529bda7c7164b321304afaa6a4adb71dc05c25f5e5294b69b21c75a13edec9f8c0a31243aa73ce6592f1bbc84c4705daef99acba57280dc92de02e17f1b28473f200b3e4a8e577312e51f1f79c06ea49f9f1a27eef83ed0749d5eb6534f9d8ce773e94f21407cd17154c644d8099b4edbeebf4401601d3e3667c32186ae79c69abb3c72c0e8220b2ab9304d1307a686c9db992808823b2b219c9f81d5a641e40be3eb71e841db1e43d571d3b225b5d811e9a0101b37891b8a962be19c7b127961ac447a847de4782680d3ced69df0c4f032ddf36d9f7da47aebba193b703598c12c2214dd41953a8fd4c2956d261c989d560d09809e6471d71c5ccefb171e1b84b806e1ebb792b40fba818c40a8ccbd07ccd5301fd00011813eadc77aec30750c84dd27a1dd089ec245bb82d93aed9f343f7cacd9cd49a22a4b516df334c6981cf57d9038700ef0eb610a70bc71dfb1f4d74ae3359835b67090bcf46549a2f8eb5e9d9573d6f2900efa6164528cb2298d488b7ba8df39748f6fe41ae04028fe3e171c68cf7954b228e0e54f266f447d9a93ae944517fbc95d02e898ee7f3619a02abed25e78cdfdfd3ee09521a5a1067790117d5641cde06554e7aff909ad7f7d8f67dbf9fe0ebd1f75856dd0face6d53b10230d2f605b1c1b022376c2f569d9849bf094f7b47e5c1aa5f88d3cba904de9fc2299ce60672c59b6b951ec15809a78e2beb4b64db2768a44d253da8268ba4d6b1517b03ba980ea5c2231b957018bfcb1dcecf26ef89a5338976732dbdb6f7354da85b62d1f0064a9c99ffbc36228487ecd45f4d605bb3a2f0113b756f61bd2d5bd7a75019489fb9b4807f90c78004233c53031f7013ff3fbfe9f37cbd657c61e071dc1e48a5c15f5b1cde2ceae555497228b19d2be443ef59e89067504c76df6197e899aa833fd00016741248c9db871fe238a8fd2a153a21d659c82caa6d0d5597a900979d10862ef7bbb9643b8423a704cdfc787bad8b06f693e0279e399125db92681391a88cd1f8a3b9d620fa3d29071d4b540fdda7d24886beff41e9624955bef1eebb27505487c6b650f941c5487db2d6a9de360fac13754bf6bdcacf8f5162e78e1808c2021468b402d2de932a590a22371ae513e4f8385cc3d54d6d8112d30b5053dee2767bd5d68b1cef07c5dbe79be7a13b4dc761b303625a35ffd50cd1a7a7607c34f76b737cad0a77c991efcc0f48ec0baea05350643839073c4d912d7f6d18ef80f1c42320c5a1949abf9500e5e027f84dd326ddc25b796e885f878be522c987a47a1fd0001892880727994f3ef4cc7bc3b009d3ab0ba12e6fbec400d978a7b094fcbd63826e5a2dbbdddb37042f9d488f82e0f6d64bcf88d327aea5615c6445c13424544d45e12007f26b62408c19eba388bcb27a32b549f048cdf1a9df32817a926ab34e130848792f71cb81f0dc000f5b640972a5d1180b3876e2c170e3ef31e27610e5db6cf50077970504de9b6354284bf12106151876524752dc34024d7c353c8618c1a0f54b958edeb421d5d521470bc1edabdd5106b7f89c1a5d52b36c7491d76d6d52c153e17e692a1ad389a57564aaa352fabff8b65dd9b18b6e76feaece6bd76f09a88d60cc344d1865aa7b97dcd9b7ee5d869943a0aca6189289cbdfd9464cdfd0001ba441f650b1c2d89d985d28731ee44502b189fa4ea4e283e9ccc8f5aa2026a3b761d9c83d2823ac20f780b887d2487f900c5f568f2059f44c2014b08d7886be7d6654dd8c760d82a2f4bc80e06760211321638b0c676bfd4254fccb74b497e866d33885345ef0a990aaf150fc2d36ec3a7a2b21b3e10356b6d7ee5984273f34f295ad3c9ba5ec4af588da45a4b512587181a89d3cf11cccbecaa9a590396b63f27ac3e157a08df9aa19867a7729910a02ef994441cb0a733d957c5e41351f8784776b45246159733c6818c817f4b7f219a68c13dd02b0410b037135025fd5a07f320f44a21926c5c243636bbc3a0f437294bc019a8bc8e9e14795ea712ded2d28092f38d55599ff2c0dd2650de43a589a498fba4d1920cf9557aeef01575efb7139b8cf10b6ea5ab3ef9b40d4a90977ab89c55a5af3fbf0f8a72197abc38dc6d6df406cc7531260a8d5e36d3ec1ddb95486596b45977c1559892fe96ee1ec87d54083c8e88fb75590898be9bb956ad1593009b68285fcd60e29a392130fcdecf3680c4f08fc6aed56784e471ad4dd0d0146e0fd41c4e17d1e660daa6fd01634cc52a48fc71402242ad1b9a1a42f254b433769f44f895ec40119b40a9f731e07d5b5a08b65c2ab225a933a92889e4da908332a35de27bcf88db00ae7f7d4fc3270b4fbe1cddd3934864c12b77d6f109704e9c0835742abcc29dde4bcead8b0c0a1a08fd00019cd781470ffc9915bbffff9b297207280aedf02ae6ce1972e2ee4f5959aa022fe22098b388eb542ca03a1af83a0f526eeafc95b192c2695eb74d1e55f6c61a950402deadb11bab08257124b0ee26ee87e87570aa7c615eed73c12862708012e2ad8775444fda2687455a0c2e79d9c87e2c8b6eabcc3622c1bdf14f94747086ef33aeafb3b282b1cfbfe7e20bd20e57a00cce137c764a07a8f25e96cadf0ac678eb79938cc592b38b299d77d3d2dccf1bc3ad3da77d15bade71085176833fccb4f4d813b43fedfea06496c732f0ff2898fb0a13ccd272a51da2c7e2c92c0b47106deb290f12f1927efd87500483efc37b35bcad9aaac18b7676d4356e9080cead811cd4046723cc636a017890c78502f131ed1c4f2bb1c67f5a3095a1f39362b5b1d769e202127eefb4c36b280265946cb8519af11524abbd4d0d35b83d9516983b7053f3c5a583f7616ffdb271612030cb06448ce5aaa264ffa9dab04c64cb246fb188fd20ab61d2e39695d47564fb8485e003a517b2f6267c9147da7051ed4ec6008140c144235a6b9076e23b97a81e347c8a367d9c12e0d775a378337eb3b78647fee80b99107d47307ae73c15dd22a4210f98a5e4b7ff6ca8ea79286ee5acded439f8633ce730ee68947fb12a323854ae232ce75fdfa99d274926b14f81e93279f5ef42cf7abaf5e4db9dab5e1ae0442e9a4816d9df5fd1c671ffc2ff9886c50ca400807db6e16ac5cb6fabb094429a97f7ae57639537b30b12f634196798cde9c00a85fef093cd983ad3f4d9fa8cc2168a8331f07fbac52c2544defd008d5d31905a6b4f57e2b786c091b019dd4a23167a457f2adef68fdd71a4989921698a451c323faa2870b78f555c3c30a56319393d5ba640ee9b0c43a2daa29c5c080b4dabf229814d08f0c3c7e21b9fa04c76c7d6c3f12509c060015fe82ffff8eaed0caf49974478bd49b94e1f710945a0c233577808d97d435f09f9193b1e7abe8aeacd1f6f142c9eec20d7427cb919262b8a81372af0523fd6c219b427477da7715f7f07d48f890b751206819b8693faf2c8f6b1cd42735ec457051022d063446c58e9e23a940081d24e2fc309cf1390ca944179e6dbcd7cb4e39832f3c8cdec876f8d964cddf3c27f87802eafa33b2ef393e59fdf9028f1add7988eee4140257fd5b420273d9bef73715569b0001ec2cbcc13e70f3be6f0dfae99b504ff2fda3a3bb4974ea056e1fa739eea46d38af1f2cf3ebdf32887e7ecbb570a3633c5e2425f29d24a5ef1cf00fd00016100ce85d6fdca9575406f04fbad2d9cb4d7f6fa1393be3c3d6fbd5eaf7a99a02f42b8c373bdd03f7c76a9de409f943519dabf438788e2d96b34b7743af43a0b01bf8a080615013519a4424a5ebc90cfaf822719f1fe59ae708b726307243bf7cc399be43050e8b9115ddc1cf4c2e0c4b2a6a9674b1b45584d7b4ca779eaac889dd720bc46bfda1ba2af747a8e53c2b3b857e1e5b607ea5fb45ecf8980250056134dcdb481bd915bab49031c6ad7e3faaa58e39952119d8729e317e15e864512f0bbcc6898a71cfa619fd5753a5b32bf98dead20f99284042a0a661297d468445d982d9159c44fa344aa2cde29454c7b08f3ff4671f23a4d062ec8508b67dda681c0fcd0346c255f430aea7066ea78b6571e8493274db67d253968f033f88c42a90f40a8298aa3db289f0e5ec038201892272b636d947f6ae9c6342e5f081db2b188a9d3f50b2e8fb396fe189ec082fa63fefdb33d11dc2771fa1fe04b23438cce2bfedac58a1c6b6819cb7b02fed3d74e8fcfb839df07bc474ccd8ee76cdd35bd0081ea358b44b3840116487e3f85d40ccef06d78c631423996e991df95018dba5db3cd0c639c4e76122db272e4a59cba263a26cf5877481b93714bf9d78b019e7493443c73c5af84cb6e9837b6d809da037a573a6b68cfd8bda5d02da0c50743e889afd72406eccbfb255f957ad00fc76a8c117d182363dc9e914dd3d6cad81e32bf00fd000133a5e5ee9bcd22e54c18d8ac925859144d32339b2bd00c32e8f98b754cdc3495143c425da51b3db805aa3884ea27c2ac815e4f5575491bfff800fb5a3c1fc0120fbc33d53ca941115350be844493da6e3dc40847fab916235bdb3409a356b9528278102b4d96e93c27c88a081bf0bfc4462ae6a2e340832ee0c76aab12f192f58b4fb5fe386cf566a762f83bfbb88d1859a10d08ec78b01536c3dcb69e9a441f9d2e947e898dda40f57fce5f0e5184d93935fbde32cdb750c51d57cf7c2be917fa299a01c41f2a1018d435380632735f9ad2e958cefc8837c21172bfe67a574db3d8223eba4d0327850bd5fff38459d4fb6e00715ba7ee5355605ea0de209ac7fd000112ffd4061db7a6cc8477b6145a93f3e6fba20a368be255f4e435dfa8c746ffeda600b87dc3e5fef1ffb6e917b09319853adea9701d795e244c4937d25e0cb0c62bf4f69168aa82ca022f6a28a7b85eb1e8eebbbaab30a61c785e19f59232d273d936e4ce4b9c62ebb3ee2b96e90a5a4ab633e9b3704fc0f50ecc5b9ad6f3843576aae92928ca7c8d09e3b87a6281328a1482bb642005cec7dd57f3ef9b1a167387511eb339412c109bb94b57c4e46e3f33d6fbbdeee42e60ebc9f44f7530b86a94bd9a9acb974c76782e954e295770716cd216b83036fee452fb2f83ef077d673f1126ed412c8d9df216b0cbc72456ec8e2932de9539cfd562ada45a389a4d8f808cc78aed67e86adc2cabd1bdc0b21969cb52b9b1f1a1d808d67d8e8bd478f293d9b81bdd65949f5ea0bcf48c6fd5995ec992a273f6e335e7e6969d008590a961240af5ae24e4410dbe1c6dbd001f77406731348a0dc4ce2f8a683e4fc7a49c659207ca2bd8fdcb31d39e58a8c75f76031d5b65d96f0f1af90da9306f019332558135064b7f64dbea34cd68c039d4dcb703f63a0a1effa946cd1c9bed59a956cf85b358f4db3118070265f8aad6c540b066f29852e005003666316066b324cb037b9b5fb6ab8b2908b1b5509e9e0ece6345cc571c84f83dd0efc128ff27a1b5dadf0a0921544e9490d13fc82df1939382f1b6170e8ca99b38362228da418dd219970081840ec70b20c096e1be5b162d058c5c6db0b672d4e555effed3ec8ca85dd69afaca09d85cc206d179994b6a0443ceb4e65071ac7a64842c8a6b2eb8f89519dac433829865747586af18ed1d8d864b75baed59daf5d08931cf47dd2c802545505c9ae8d351ae00efb35c5725f8d3cfd87c7792b084096af67c7f63bccced4a038d00fd00013449e73edb7c7fbb2df81482f1d22d02eab854f7e7a1362e9a41a95d0a8ab7bb68816dd19776e0cde728b6cca402b4271b384f71053bf501ec8cf40167b76661d11aca1ddf4c47421be72577dff8be5ca3461dc8cfffcd99fbd7accce7ebfe12ac6c4f8d265b1416464f2b94cb93b40957eab9e01bfaa4e33948fb2de3cb093db74e914c3f048eca8699735e3693752fd59dcf48cc92b63594b8595052ca405ac9191c6ad3cf08c6d92c384a8eb0b643b57e8b9f91ad94ba1c7f5d8aeff0baf1a905ae1d722af5d1d4da2e56694a7857f99c114eb63d6914b0ad466b6ff0970457730cf6ebc607b1064ab3e792833de818ce7f47fd212d98dedc8602d90a08b281fc8f5ebc335769ddebdcc8fdb0507d0d814fe95333b151b6518abc1221bb431aa83abaf371451e4ade47142b1c1159b372fc95380aa1697935bda8ac28ef6bab6c69d6871ed0242087f1e69f4ebc71066bac94040f79e5fba35c0bc9085546634d5b1fd7f5c85577fd7b645845ec87623eefaca134432ab7663dc7f5a66f55a80081cdcb09b132c40a0e33f8f9c47fa993a3d3c78f5b4d8b7c0ccd2e343cfd78819fb9d6556e3ad0acf67c85cf5cdd9335665761a091200ce34bd81172e0bc87efdac66ed1d4d849f9b1e94ed2db44601b8f07d85a173a6ed9a76ead0a21d48421a608e17baea8e9a6b319c0c41fc5917263f6c93208f9fad8ae2b2eea2f702a2fb60080f98b3379369444ffa8fc207955e7b01575c7007ed19ec7291f28b93db7aa7148bffb9f98f7e3ec1e1f21568ad37f91c4603d3f276ff0fa7e9ee8ade05c277775c3ca2440d50d427a23f7aba81b8b1b9bf3ea8fcddcc3c3a7708601688fda8a6f41d0cf7b5aa4a03422f332012ede8fa6a9d8093980f07b87092bc6e48d5ab343fd0001840e370e28d6cfc75778bf5612efae6c33b4dea0c4964e810fec77ef1875956edcd2e22139a485fc4515fe44c57149905efd72b16f4b367735c1e91727632507aeece411a472e4f270e0bde542aeab9961d4fd0898b821a6f193dd42391de664331e33b9244e0598669269c73125b21765f048e0c2b9d17aeb0cb3c112a318040ea4126c43e0f46d1d9c1304d95f35b875cc2fc3964970e3602cc51f2c496f108f904e2dcc8113223a9a074c344b42662c3fa22490db6ac63a1b9abf0dbc97feb0f4447eed2e96f33f854f695ce54e24b9ec180cddc752cbe66fd361d874873ea3733a4ad92870b24efbd22e928deecd7d4293b680843d75127c0eec3f07f894fd0001266e0fc8977335a776a66b44c25dde8ac3f40f2d195dd3845a0019b5062043d1e1f24adaaa3052565db20717cdbd769bc1cbad88c0fc6a205fa4acf472f954d161c3450cfa0c622b3dbdbb2813af60d47870299c1f3d793302c678a2d4cc7705ee51b675dea5955fd7ddf184cad643fea3f1a428b6d49da35ae251744cca95446811aa79d4edbb1399734c3b3d71c7ef455eb73f72ff013938ff65ffe3d8cc8ec321328775db8884cdbf9645cedfb4d86bd54cda89c7a4da2da5dda4d4f459518e058d3614a4a9475426c64a16bb206d2a02decb9e301ede48dd0247d36d5b9fa1e449f44f6a3ea7999f31259bc49ea13b62cddc0f877435901da6f9cde2e4ce816565e8a37e36ddc7eb0d1363f2d0641db79c0da0fc7346cbaada28e859cc7bddea3653151df18260a7609aef925c9de21299857732ca58631eafe768ec58752d63a48b65895e428bb4054b8166e61beb6e8127488941601c0ee1092f695ca0fa9d7b965eaaa4dfdbb9fb2127a75447d02f64bd3c4f4e285e781b02d98b21089000fd000118a3777a8b2c2a8e5e9f7fd85ac71b6e843ae5ac31d3e192b73c830a33eaacd7ed6f9d5aed4754b9b6af55e60dd31ced5d74d2910c2e9a500dd3fd8e282136337919b3e8faf81a96315f04588f7a86786b922c6ada489eb90bbb8b1dcc85e8f6c6d3d5fc561f4ee579e9143fa4726cbf168119fa5e2b1539327327f00ab363eb065aed240f5d120096951933dd3e689118d2262e01e5827c120f53f80dc8c7207f2b633aaa0ebd350e65882d7e9069190163975682eaeb8570c6c297b614a72ab6a6c3276f754a7fec8ef86c50ebec46bf88701ce0c3037db989247de5ee7aa731ca30af5bfe9357e3f0de64160b9ccdbb0eee9885478a6e9aab6902227933c8800ae488d64c37f7e8713e92a1ef4da540c5da96b55f67bcc7e5b3a0950e75c0e75edea568a951e213d8713c034245f6d6e307cde14b2d7160bb2ee6628d3ab486d2a0a9c760478b56003f84f6ec15c6ecc44ceabd1d1007cbb891c005f62fce138af30cc236c0e31e0d93be2f94b9f3a7ecbff058bce5fedd4bd294ef8bbaa0588002c6177026c1525bf82d44c5458f84a9105b3f6eda233d83608b024ab3aa3646a9baa480c983df90f90be078d68a80292b8a609180fa075f93e99590018c49e8eda3098caef604bbb643ea3e4a0ce82407a80a13e5f5c786746571e74883e7548d881a9ad516d0b7ae5018ef44a5a93e227057169302cab4666a35b3b22ac678fd0001df3435a26f04bd17de1cf54277d5e8b52d4bd552f2b27115e6c65a252007b889c3d5178dd15259d2216e0e3fdbe065b38b5f638fbd77ade95f13882aeddcf59d508f0252647a0d705ecada91727ca37541f25aa4061a9ca2e3e3dd2169ac00a508db5661b41cd1b626a5d64e9a093b3509d86c122264b53c5fc95d013c6b8d58a9faf515af46d5d41610ab99555c2c3ab363d604fa147b0f1bc86a3da26ad8a4614e6a27f84f58b02b698c232d6a6d864e49d1fc95aac2fca78e1483c53c6344a731ea31261a19bd5b7190d9ccb8ed161d963d4949d17bdb8edf19d1bbd0fa9311b2d5f2b3febc5e4dd6e3c6f2e169ac81a671aaad0723ff8e6b0228ce57ef8e81423a9b6290dbbabe3ab5e8cba4c4e6453766806e946f6261557c2b23c05e7aace181919a12ac324fb26f709ee0eca8b746eeead2b12c13f01c39d5b117bdcdd39fe102d66bccda50456e8994ef9924e22ce634ae801c9cf7ae24d72fd568379bb6650f77af9dbaeacdee02719fffc07ad660fc01b84dd88f35e6f97ee3c977b40080a08b918b6a42c1e2cbf0231135b5a666a371bce41d2b53ccb47dac374dd8a1b9d0469281570672916c4841692a836200f9d4dc3d69b71fbf6a51ab597c6b11ee6d772fdcf02350817e4d85f79437b5aa21ed0407fe9689128f9166bc391ed499357d91b262930834e96b1fa8505ebd15eaf85ff38db7ff5e9897c1ee9f5f883afd000161acbe21f4e6258c082bcca1879e68a20989c95cd5f7fcd1817b807d6819263f1d32cd7882282db7bc2a94b5ad3ff053198fb7d89b51c0df65493652932b4af07023ac9a84793269798b2850d1296aaacde7fe3eac56b88ea9f6656e4576b58ab9eb13dbffa731c6c4a57c2d06fdae1ca33e1fd07851afcc9d5c6021a1e0b3524c72bac8c9ebce52290043b3596e9bd0639220d164fb41017e08c632fa32c798c61c643af591b15148d585dba6baf61948e53d74a42afe3e45505fa249c6b6429cb40108f107983b50c8acf42c9822527413d866f3605812c3665263b0796e7a0c632464c131963eb1b39eb54b6d117fd25fede83fcfdd5bfa3edcee638196ae80213f7ede12505476e213d56a89224818d5764679824cfb61f0365494e5a4f26901aec701421c83145e75008a4472d66f06ebb3af51b886a2325ec482969d59a86dfadd0073596b1d47545699252a94475324ac07619c4b2664ee6f3b83dfe1c7ef6ece6a73f1040016d887adbe9d8e076de14815f8ad6bbf89d82b11c8de622d8094d122e7cc525f099280b1d3bf5557fae729b8b4287fa3f8a92623ac0cd62ab57a10f3fcab1493385a909c09e80ae7f7f0d8bc7459ee95930455412ddada18aa6bf8f8d98e5b7402605066cb4b6b5ff5cab305771cec6fd000032a289a8722d0a402afc66696798c6106be690d05ada3add2ce5947851c79dbfd323dfeb194718127e1ab87badfedea9cf54d4cccdbabf719b5d4af7f3697343b59974b5e263687aa60953df4a207784484d8867fef51eee561964b4b085ba35e822c22bf33e7fe10480f5a63666a5c654c350390521cc06b63b9b215ab1626ab9bbb8fccfd1076140cc362ef54ac515a2c924781db8d102e9b8b64149ebdd98bb102cbe31305ba0081c572ecc50846a7dd2e812f5965e3b12f571dc933b0bbc7492e8d4a593191e01ff9895d3807afded49d76ebc51e0b2b0072cf3baadea00568925088a1a6b1d5c659fda5a6a9af46329b067f5ef1f80b2b5ac6bce86c909f96801fae3f620b9435f49621a88f47da90ca9d7c8bb3d52a01897b92f78f60c64c9b7d9b2e7a1503a000fd00012328fc8b7f3b07c470ed2a03147afb9dc212ab82e70241794f999e711ecfdc51da0413b06167d6873a91a8335a5b2df0cf067681355bce28f19f5fe2a5c74dbc334d077ceca07c981b94c89f5b6aaa03b70fa4a2691be52d76461356320efb66d1d30a771132b6dba09c35de3ac8d46e9894a44a5e3757f17d2c218962af03aeec834380e2137479343535fd8fce9f21cdc55b3ba54a8830892a1afe865c39804811be558a668764eb6c206306132bbec65d21de4da92d9da947e28bf1fd72dbb246f3d5a32bdb7c2c4c6554c80513858b8b863f66f90df7b84299ab692b4f638beced0e40179b25ca27cadae375e6494298f1af1a9c6c8ca4c27012bd5a578dfd000100a3219c72f3226a3dddbd68ee35facb6b68f03dd8224e56e3f09fb6c1e8a5828471890b83eacb0acb587e1dcada3daa1bcd82e86588a3bc4258cad212f965ab7b7b67f1a201c3fb65fea00edb539524c439bd574a52795c55307dd5c69303e9acbdd82d227a866708e391d39f30a450876879e9e96e1166af40843d022a98b6fed4a3a75768ba478c8c51937b0feb9e714901bfb03625225c8f6079fefef5116d58da16284e292538496390666b292add9794937abb940b69efc3689de9a1d881128212f3da736d62a629ee1b5f0d0b9f14f68619352a190694a31d7cbead5c6e88e92882d58ea2b4b2d7fbd9a21abc42fa72b751c30f4d1dcd2b1d538a9ac580ad45380cf5586d1862e7b5cc1f5406e07ed7c9f8e2d60b51fe478c8cb1d0419eb74699935bda7fd39e1bdf588eada0a34c61b50952c015c3348b140b862124143c5a6bff8a95d55e96af5282d0d6e75de6b4d018cbe8c314e0e6ddc58b3a9769629e7b668119695c7454a83e476dcde3e823545911058b4a3c63e34d1045296dfd0001a2656e2e929437093f84eb1f5bb7128ec028b07421266121318b060f0de4cc8a979142cb6d18ea76af9e768249b046b79e6c134300c686c706266386af960f5139cf50208344458dedf8427753aa31c102e5a9c27fded58c1be68b6eeb1e7486bf7ae4089f189ab4b2f4cea1390749082ad37909c56bbd385a3d0ca67777c50c31b60a9055f4655ea679c3fe517e9c230a83beb74707d7d3237343972259908c0844814e73c838a8f476238a764c89fdb7dcb41ea693d81e4dd679f51cc7563e0b317336eadf517a3835e2d23ced4b898a4263a37ba4c40655240d10cdfbd608e59a960b3bad9f86945c4123ae65fee8cd0b11b8b3e4ce9186fe40a0d184a7b2814d798b5e305448d9efd326751ac2b5135e377165ba43f41dce4d20a469df4d161006f30ebdf5964229057ea556cc9c94da42385223d8d4a9a68c98452eb4eed7efc9ac9aec7e115e6f0ee4dd8a59f0c3af9025c27263a0493704548204fcc9d9100493fab6bef47752ed0c197c7efec06868d5be4c6a7085e44142d0681f9cc600fd00015fbdb2b09918819b56dda812b5213c0b2ad01cf668900c07402e80b28de1286dc3d073e305f0ab61ce03c3b5dc6661e8607985e0db2494ef615d15200cd441a409d6b579352cf40d8afcb386f2d2e6b193a1d4107ec1dc395c5fa542518356a5d2e60281396b45373a88655a899f0440964b6897e59bc6e4975ca80cfab2329bb4ab9e64d55146acbae756fa01de037ba67f1b32c1e3bbe3c897d442d91f2fcec723eb41ad81ce587f89362480642981290750dacf2d89ca70b317520c13d535438e7b3baab43f0f9470750b3bbae829dda95da000ecffe01a4c78d0c62c15218a88a31afc3ef3f8af914c5975cd9d279491c849265822ee1acc28384e5fe0bf8037b0d912da535403f5b695565c69b8b0354f5ef2a7984ede9b647416cdac8e9693dbb793a8dd01624515b5839d8c9df43185e5a534b02cbf5211a5a76901a5f290d235514f7ede4c384033c438aeb13e916b0b3808af47a069d98c7558bb411464d4f6f47b9a27b0c108ee0105accc00990c74dca25dc8670406c9515b6f381d819ee591e0d496a46333f86fee821a2a99c57f033ddf7f5a5ae2ccad12fd802e2466086269dcd30d5bf166d0e75e78ffdb17314b500eecced0eb89fca7b80e7b072c1995b83aed72d5b033c20b31eff559d64541d17a97875da9075fbc3f2ba535b2cef3b7162a395d150634cab96a316c7c53d4ea11314a33afe53001e41ee3c50081cbf279e81c999aeb762f1ac725354d6e4b1fb7c81630dbeff0b04745dc66616ae25a2454e0f0360bb18079c1c76ad458ae27b904e5b878056d0e66ff205eacbecf2abc3742639611e2b638500423dd50fbc25f9972c1e4e9c2d0e84cd048e6c7cb22b11b3a2ff56cea64b7bd061ecc395a4d56c24be981db2975c234be0b2f81008005b631efbb2fa8782f143b7de38c0de7fd23c704c474c76ae0d36e8995db201572252634de4493f1e88f9abe2635e004a84d27294256a6515b003869890d730d1b9201d75b3fcbdbe0732d4c14543d1be2ecfc827546b7be930d1028972715e99693f7daa4a8a240248d07912d9fdeb64ab0683ea51c2f4d3dcc6787bae732528012df83c70e6e3600f7e891987318ad29b9289ea6b2e2bd82ace318eaa84c2fd8a490b6d34bbe6a75655a09a29835a26a53cb859c9930793f1ec0e3b178c58bb860145c5291f5cbed4c3eb998ac512c603964527e1e5a0f5356ed48b3d0a73adec895df93a5ef9284231cfa621ff60b936bebf4aef2a744a0fb7d47ee5f43085e802f80d547703739c5c00e8dc4a72cf3995aab570f821f775544c3901f2dce970d1b4129e4c28b57e8ff266329e0a37b6931fde5e5c54922291fd305a948afbb18f9effd5d0f60a60d35979ea239b80e249ef70b39f36ee312a4167e0aec0aaf23bdfcd7c53d64249c6351a87f066f8f9845901b7f757a05e0a463133e4df6571981a2c26bd1cbcde2cd0690917112f976ecd8be98df9d99fe49f98a11b45f5eabacda1ab4451ef9cd3d1abde3ab3961a3ad111786470e3ae064b5d3b5a16576834cd64ecab08e6749aa502ff3b7057b7ff751c234ec9b08ea5e4e0b55604114ce837b6718e2ebdd73d1b4cdd2307ed0f1a6123f4e3433d3c3fac7f08a89975ee59d00fd00019612c87cccbc8ece8380fb088fe8852362b7702d24848e60213f33efe5ef71b6d76dd868998e2533cb85964221e103dad6049dcaea215a58610ea64eb88bcf4570a5cec9c88ceaf735f4d77d676dd6cd2abdc1c2a9d5e8d625927294a464fabc08ed5a769e640320af871b6c10c0b36ca09c398de5de8932120719d2e71c481536250d2eac410ab88c4970c68b996f15e4fcbd4090ff80f8677d55a8eb1274436ead2f15cd0f0a141d0706fcb3eab92392ccaaa36bd7f4eb816062bf3539a4b5483191667b1db7d13b5d043ff2c4a105cbf74a5f8450e16ef9e56c521865117cee88c49480177b5507d2916af69654cf760c5c5ec886878c27c2d3c8188072c3fd0001dacf4c63a866d5c2bd21ecd5441afaa3767666957dede948dec007df174f22cd53c05bc5cd2a36ca827fcb8446cbbed912759302cd005ee6a2c69454a3477db36bfac8c9cb9c1bfa913cf4c43f72ee57ad9a4c9c8f5551af8385d855226e2f18a55291dfcc1ae5d60b8a79d840cc539b926be1983c2f16067b104e2c63d8253646c89d31ab4c50f166105a04c19a10ff75cb467ee83717ed391c72464a7fb3772eecc36434bf946de0d03a4d4a5de6c4bcf8ef8643322167d73b424828c43a14dd635cd9db1313e4d4ce5254746fe90672606a0ae15ee5dd388732ccc0d3a4aa489480e6ddf7e6e0556d469166a96181cc439500ee7823f496be42c2950808a4810f484d78b6727e793fb2bd80a89e39bd21df6fb17584aeacb871ddc341cc6b7d10e0a476619f4a1380db2776c4a166acb9109a9a4e3e0d1c722b1cfefc135947ad866938137a8d8af919f4010e380f0d4bfd086059207e5a6155a66325355282a7453cc87c698fa58dc8c3575cd81649ba8ec17ac529ca74957e55b9f4bb56bf00815b9bd44f0df8280a75a1c07da606fbaaa180fd975cf0fb680960c3c3e6574f5db9ee72aca6974ab6cd40306bfbaef658a3613045af4e85edb81331919d3b9fbaf742fd7873c20c7ea1460abf68aeb1af6f3cd8efa6e8c072220c3c5aa34f650d1d946e400bc038be346c92d63a2160048aae8acfd9d14b707333ebe2264080b700fd0001d720e0212231342c3102b8025ed9be48f49d2e97999df5268fe485db4b86020262c62548456cea96a3aaae658ec4fd624269d96faa62933bf6da9f251553dc3085f61ef29fa80e09d651423fdb6feb6ec0fbd5a0e5cb370aec10f239d2858111df0053151f4697104af362d12224e069e0efe3d23f3580fa7dbdbbe2021882b7236e1cb6e589c30427f5afcb414b96b5223fef25d08efe1cc1b94727d05abd7b5fd828d8be4658e0f4467314f3623fbce8484fb58258fffbb0dcd775c78eca1ba6d0a125412db9915de16e6a7bfc80ce05108d8a1a29ffad260498f3d7d505a729b6d80f78cd7c84176c02fd37188433a8481b1ccd90239fd7e7041e2e9bacc880eb7e639dfa8733cb68a4cfabfbd894e153b67e939cdd425a17ef4cdb3c77b204dce1479bc7205b856461de37523dfbf9ea529fec53f78e737ae178bdfc1ccf9eebb12eb9fe4dba614698043db45384cc80ea339e16b8ddca87fb472f2a9ccb2b5152cf06306e3fa48824e5a3fd37e9552feeb586f426737baa0cd20b922235638096033eedc83083b149b489f7cc907ebbaf77e0a7145e5aabe02dc425aee3602ee5a8e33a0af6f84cf8acdc541c7a1626974b21a88088e9f42fa879852ecbdae5df0c7be841051f73c86d5e9af4296da7b84756afe65101ad68ce8150a4f44894c9c3883c7db6b9a8cd7080842e712d37180485ab416d4e4a229270337f4c68491f5457ec9b3b6d4ffdd935903f8e4c036db95e032911714af527430baf622582e1fd97f6581822672800bba9ea246ea960aa19bb71102cea154dc463f4a020561e2a40e835f9506e2c1bbd37b14bf169fee9e41d94d6481153999c6486b28361ac1db2896543a42d0e8d1feb8c9f038e3226b19daf72d454dfd6d9928beccc3b3132d1f4e996ca49f276000e9f48d9ef6726c487c0fb0ede75c69464278892803a74fa9ae56044cea55931ab6c214fe440522aa3770963ae910e8656eb8abe6d1ba0f0633644071a1cb7dc1111eeceaa16a2c3ddb66a555db4c412337dcb28895cee7cfc1dc8304a16fedf2f7de4fd1160d528087c2f55ea33a2c7feca95ca24dc71e1a6f0d978bf0d36bded077f5d55490ee150f2f83ed8009cbe76ecf7f922b047052e83da4706ffc1e2a4f19b3ce0f1c1ca1279bb60d8b94069cfb3fb5c328d63bd821e47866b6902a3c85a02cfe1be4664418a32eed422709c5a536648805b726b053da8269fa65e5945c3f0897566fff4a9aabb4df74fc48378fcaf9cc69d7cd820d0dd682d41af39663e3a12ceb63a4f5a875d2a1df3aa924270ae2d4640e53f8edaadb3f53a8a460dd5c7d3c5cb54cafba1911314d809091d593083dcf397a2e4b9258ced80169c0d7728b869be03b5882045a1afbb0275b1073bef509f2db72fe133df00fbb27ee2ef6ce6fd0e2608387175de108b1fb40b74746a337531375bee5563b9b2ee6b9d803be7bf52b7013f87bf4b7481641b2ab5479fe5bab4409858506ebd120c8350a53ac86e15c8c12485ab9f58c218c9e2f44a39633abcc333017635b19af3330dd4dc275e9848912363a85e7cb3e91ec588b171e936af4a63768edffd74fa05a2fa9e281cff6eb2d801b2fbb8e208dbabdffd387331b9239f53a80414cc223a6230ad96143e624f210d541de65584ebee32586c31be681dd2527d2a52576744ebeb91735f4ec6cb2f4bc8f94dc0f810fbab5e14304c370ea8fa4c208376712cfedf6dff2c4cea55968d3316d87cc68f0ea97eb59e79a5822b9e6e69" + }, + "sequence": 2 + } + ], + "vout": [ + { + "value": 50.00000000, + "n": 0, + "scriptPubKey": { + "asm": "OP_DUP OP_HASH160 b9e262e30df03e88ccea312652bc83ca7290c8fc OP_EQUALVERIFY OP_CHECKSIG", + "hex": "76a914b9e262e30df03e88ccea312652bc83ca7290c8fc88ac", + "reqSigs": 1, + "type": "pubkeyhash", + "addresses": [ + "aHfKwzFZMiSxDuNL4jts819nh57t2yJG1h" + ] + } + } + ], + "blockhash": "f93684130bc3e655e6a350a7a392d63a0ea2f3c9fe0a2aed5f5a97c6fb7ec367", + "height": 11002, + "confirmations": 116778, + "time": 1481277009, + "blocktime": 1481277009 +} diff --git a/bchain/coins/firo/testdata/txs.hex b/bchain/coins/firo/testdata/txs.hex new file mode 100644 index 0000000000..3431b9bf99 --- /dev/null +++ b/bchain/coins/firo/testdata/txs.hex @@ -0,0 +1,6 @@ +01000000011687b1470de50d78794fdd86d7d903345f4209497235da14a03646b0662d3a46010000006a47304402205b7d9c9aae790b69017651e10134735928df3b4a4a2feacc9568eb4fa133ed5902203f21a399385ce29dd79831ea34aa535612aa4314c5bd0b002bbbc9bcd2de1436012102b8d462740c99032a00083ac7028879acec244849e54ad0a04ea87f632f54b1d2feffffff0200e1f5050000000086c10280004c80f767f3ee79953c67a7ed386dcccf1243619eb4bbbe414a3982dd94a83c1b69ac52d6ab3b653a3e05c4e4516c8dfe1e58ada40461bc5835a4a0d0387a51c29ac11b72ae25bbcdef745f50ad08f08b3e9bc2c31a35444398a490e65ac090e9f341f1abdebe47e57e8237ac25d098e951b4164a35caea29f30acb50b12e4425df2880faf633000000001976a914c963f917c7f23cb4243e079db33107571b87690588ac68850100 +01000000010000000000000000000000000000000000000000000000000000000000000000fffffffffdb65cc202b25c3200000046551190596d29fb87ee282c1e2204bee5aeb7a1b1c1c28f1d507ca1b5d4f4a351f4af3663d653f8b1061fc77b2b7f72c168414574007b360b3c59f2dddc39519ec1ab30bf290181d1dcd37f4a1e35a24d64937a05be7efbba8c418fe877092be132ec83c77c4098f059ddf947e1aec7e64022acc17bf8cfced88d37da3cb2b2e0105c555a26e42f89f842b219d60ef390a8e998967adf46f06900dd42059810b56112cb23660ed591f4de1eea034fe181a6b1a8285e35212cbc3e0c3f29a138ff6aae9c91ea7abf4e20ce2dd27d7182696963ba53fa57d1eaceafbef2cc814d0b17b19b560a48cfee21fd69025902c23b8ea9fab931a60cf041c09418560020d47a746358826da947e16206a1d35d9879a9d785988bf300a1ee6641d12fea79a3991102d6d8f9b628e5402b0c357de333f9d752df7288ae0e8a60ab910694ee28a04889c52ab6eabc8b890c93fd8129d211357013ead3a8603be4843460cb25856936078045b5b07d1e2570fc2d0f45341827642c3a725a86e07352b2b8f52748e2be7adcfadde26eb9508a93fc5305551b9fda4fa819c1256d868c9b01857bc3a5ef1db57b6351557a53c1409425343abc40754cd121920eb99c92c711c730d838a129b801b2b152ff3b940c83c70addee716160951503eba21720f9859454cab7785cd7f25ecf3846cca6e6c92dd993268c268a3cd1f3d3c3818687f50f5423e658ebb7afdf3f6de96baf2e61b344103c2d16f20e31873d30b38e4a19856a8f510f98e74b819de5f2d208ede4bb3066e8a91d71f4a68f5901755a5faaf54a68316a09fd835f495018f2455f01b6470f8be72360d18baec83e89ed5064a87dd0cee41f57d09f87eecc3dc012f4d2d316544126959484d625a7922f288e1699a5b5b672c44cfaf1ceefd0b4683b1e7a62e9a33bf32412f1a49f1f8a0570dcfee53b9db948e35b9cd545e74e0d024ceb04bf726fe3c323ce002683447beb33788180dcad0a15569e968f185b907b24f0a91a00a237d92a5c2be6d752b27e06fe7238987cf7ee3ed0415a1cd0cc69b8eb586fd6f7b83e01692d9d28b59b9c98c231eb38165d42e62c10cbe4246bfba35cac79f0e002fda3b06941f4ebadba9109d81355ca6d9b0ec463ab4f41542b9cdacbc3c7303b66e5ce54fdb33f1a4e12d069a3154df189ce2f7340d95433de251da4ddf967e000fd69022b80e7bd4378a9be93d9558d63c8b2829c80e9ba75e4603bdcd45a9e100db330dd8017a00cf3d317c770b6d6dcb05cb2cace0e296ce2e8a96b71b0b6ea48be0e2e81cb66e76713a5877020a98acea1230eed97bf80b519b5dca15f724dfc754fd3150d2056ff113c9ffca161e13603f0acdb311614a44a47a2178f46a2017e73fba20d07a1da0a9792080875aafae252a7047154ad590aa34242cc5a76c2bb97c6e1f464d65abb5be84c64589496449f08d066267af9bd40ac5b7b55160f1d2f9933ceec99b3b5a4915776c7d1f5dc2d0226c0742e0c5376bc116aa571cbb692fe53e7bd9c05aa8160d8476d40f5208abf58bae2508bdc5e52ec25fb3a037d17a162646bcf82b6c2dd8560ed86c9a67668a8ade7cce1540d7742400e05d091058fd60396dbd0ac83b54134d64f76303f022da8765a67bd00a0d178a1e97dcf747551decbae17c89c2db17de96220a82f5364504ce7114794de930a35648fbcaeabaf06a329e8e0c3c87f2cae56134acdee0d86b3941d7846e6bbe424e89d8cff510057143547dff7c06ad7326d5bed5de75ec34b3163c3c58a96cca18afe399cef35341d588ff9c15c0c8f5a5a63727ee52311e3f28e3536292ddceb48018b6035113cbb3e838c668b2725f12978e5ab9d8f808dc64ccc0ca48a02c2344e8be8689740c60cd58159e45592c55da593f5f52b1d370a5d6fc364f03fc0ac094f528a67503cbb6fe49513db62596080b728be309f4ada27ead0923de2e89ff8ccea5a00c74f7d106928214e2feeb4ca2bc475cbf3bd7b3458f4d10db64c9abc350e244922519f2d13ddcbeea3f3b2e366eeb00d9d989142faf860823fb5fac1a3e0a72a102c69bfe4ff00fd68023299eb15b9c2892d691c8f439064db72f10d485fb32bc10bedf746bdd83e33f6a56978f66b0f89427a84ffb3f2521841d75a1ef262fbad0547a76deea1151a71b9a39f0d1c8df6c0fa6a66136daafe0b4a205f84df8edb19db8cc069aad6605178c7dd49e9e1af87de1b1ede3fd1ceea73f973ece91ad8ced139754cca4cffa5597bb9fab5fab3d836ee0e04c1ba1077500cf49543bbe5c986a8194b9cb5be63721c4d597c7082d456b23a20ad036c21f416b970a344305217f455925db751f52b0559bd986dd35192f639ee698c9468ba338a7e46ac9e50368eb86e5666af8431e7ae273e14d8202a557d93e3a93cbc1261a4bb13898c9fb15ceb3211f6f7d7adaa30b4baa6c4fea881b84c43f4ee2b9a9111a55fd502fefd95501dedffebebe4fca78fff7c6dd70e90adb7b8f2f611344791968aa3a0bfa06bc759721c622c8f2a4a67851c2acdd586952b84e287f086f60540934d05faf5a267f4ba3f6c17eb15c5fe6f302094247dc9c3d1d42a0017ac8e97400361c94f01c398ad4c9c3f88e21268203e3b52086d796a7147dd039329859e618f7054ca899219485c31bbf460a1b359df1c3a025bff338a365f33f48f71763647e48cc24472edb962d435afd64f394ddab6c6f64e6f54a3568f38ae45ce599fba9314f121eb1c6b8ad3e5964557a058186829a12002b2a9220a1ab55ff478562cb333ef6bb69d4ed4dffd9ebf39ca15f5eecde297afbfd7061e17eda335cf7212389abf1fc13053298cbfd6aa6402a323d5051947347e9fba76b059206a916a4ee84ff1f48c98d9be5ace61a2fef441c44587bae69770f69567ee8f52cd91adcc76250951be53462207cf27746c225e13c2164663cb0ace257902fd5815b878e4f19ff10499acd3700828a051f8c1ec33d421135089001547dc1df5cf9a43da6877472c6496ae65ec1e7b91bc3494769a03cfc6e350c588de0045bf26d0b418e08ffdae019bfb19f510e0e530d66f8173b13826b1281575a5aa703bb86cef598a99b9546e1a241fe86acc5a8f7156542fba23ff41c1db9267708f44dbce1f75465a7befa3e135393b1d5faae4f7d90c480656b0f012d1a66a03c76a58754b22e42f234de46e7f4f05192dc734f497d7d9a1989d657fd1bdb4e2379e4f576c5ee72be808dba602fd3501319e81fe1211176143ac5d9b76a06951a6a0413db2f4ae33d0f7d9a216fe8a5c5828c5af6778cae6464dea07262b1e64f18db9daf24fae038494836e7f96f8056a42f5966ac53f1e3bd7e2a39f129ded3d223908e64e020b7df2fdc275b993ac951921549d0b1cfe6464e8a3600f21714108f5c1aacdeaffd3416e28db6321b761f973ed338e95b559ae9ff6cfcd65e62d5e92b72cb244dda8ab5babaea6b992d7dc5ddb8bcfd189b2f564de4b57e03016f578c3d0adf004232f2f2ee155af2d6d0224799732c61513f10a51405be7b07ccce65f99f0eac9e3ae73a2782e34226508fee3c4effda657412c2bfeae4e4f2b63037db545bb7353b69654dab3f5da6e05e6c801828301e705eed65de092fc7081807643d9d3a84c2c0f00e460e4a7803f8fbc60c1803783f2a2c378e07531ce57bbb700fd3401139803deba8b83a31f7a90a52292c7b44d8c854a7dcdb835a2ee349fd4034792c0e62fe57a845f2927a74f363bf8f01a8a34266c8c3901c32b69f954e08e08e455f19775d92ee0114ead8da754f4403db89cdbf7e2a26d5560b060cfcfca049fc0b4b6a284f3c8b2ca99b0a53e1fbfffe5375cdb81242e758eb5fe13482030b78cf85d1dceb18833fd999d7f2b99a59961c12b8cd5e7cf8b0aa0212334023a28dd3a1211961fc7b7d8583a35d3a89b591e085eb2c63a111dd5ed4fa7b940733658a17e4ebdfb86a9132803d71a9a8b999fd9084a309214eaa5d12c6ade1d5afecf98cdb590d5d67ad79523ab29343643f9d6fe45afb34db61d0d7575f3fa21eac819d3663c5c868b32c0b5fee74ca11dc907de348029cc4f8b9db1008defc55f5f2f7f161d8249f5a5c4e7b643526f176d901a50fd3501be7ca3cbab1bfafd3e532d3cff08a4e43615ccfe9b5c75d661abb778188b62340f9a2f91c7b4e8f921f94fd023695364ce23a1a128cf630a36e69460c732cf514bb3a6512b23878d36505dae42b2680fb5bd293883938fc4964ce807d00a3d5b5bd93eb5328ba05c4ece7a62a6ce579ea0301c8cb04f359d93a68f4752de9641463fa9ae07d1b8ea2c21015539f5687be2977116e4ee99b1230ced94c52486e6ae38badebf88859df164e18ea343305d7153ebf5c6bb8fbbebf3c47cd23411961558edf12b57bf180819412bcc84fbc999fea2535efb01563c48313f12f3f42d3757c5da59e90948878b64f868be2604f8bccc4d103868ad3c9c346049a2c66c590067b890993f7de9b8b229cbe55b7d9c0d3716bb51c53188175fc7bc04bf4b744774ad7dce79d5bd21e4a4c294f8201c1c081602fd3501a925334ef2e47c0890a6a542f8321eef345b2cfd931a0c48c0296b20c1a22f741c3d7a133756ca24ca1455567fb99b6b6da19593a4dcdab7304b5963850e3b79442602217a64245cac37b1aea73afe494057b545324279d70041fe2977232b8a04ec926664ea4c10feb022da5e3ce3ec5a8725192c3d795a614dc479aa0c099f19d13bc97a30cf1ddb36182834deeb42e89b65a6b76cd00b934bd4bacbc9d7aeb0f544059f612d1c8837ebcfc2491fc5e9f1ae8a4b9f08d9877801b8f18c28da4bbcbbaeb8362fb18f6bec531557cdc5231f6ebd4fc73f97eaaeea338c62796b05e0b84b12c8c8de7b0444edd0420c2e5dfe1e6fc5a0c93b7e0ab7f005ae536e9b30a93679b9c5425aced70c1d60ac61d47705744e88b90697694a6b6f32a5eee6b60c4f96d0cfedb03ad96b8172aae6441e01c100a491037d637954ace3da0f416b9364be62df441262e33883df3ba56e9b6f665dbda14a45434e22edc692e0ef977f3d1f902084a3342833ac2ce396859131b64f0cd73bb1be3c22c99fc91dc3ffe07862cae7a34c4384d68d4f729b1b174d55b13e03dfa1fab5af8081d61291da97fd2a00762ae441ee631e242852bc20f5ed8b62a6e4725d977c66b16ebf4daa6511f7070e31b4446339c44d0a90dca22fb29085f2e02884fdd40110ab9262959ff2a85438df9126d869e3d4f7b85044344d4067c7af01979ffcb5598ff17cac8d6b588d9f82d87b8f144bd16149d9277ef00a79fa4d80ea97e7f7e7143246addf1e15e576789c0ad716c44f244d46a02110d413d456f8eb53da3d36589cf777172c14c5d3d56cb7d61471c0a6b22a6dd9f5928fa018ef0577c8dfd5cc5509da86e2a62cab87b5e757e0fbfde1cdf19edccc2d78636ae3ebacf75dbb1121c52ed86dda072db87ddfdabbcaf9b39fdf1fdc072af586e1a091fe00befb4572fac4c8fb4f9ff5f85c13f66f238f4f287c2e8e852729a1aab11188a942d8db8bb8e6483062c8e75166584e8ae11b6685026f8145951f6ac8ca9df676ce965c2f226e5d6c2cb482fd067f50030495d5826cf24d36516ca9894ad2303eda071956582eb6a60e6dbee56d472ec998b3dd3c5d08cf73ced73a7750c2936e23836f36e68544a3b7e02fc576de20e0a76fdb1c13fa6f4090bf91ace61373ccd5e573ee262daed75739f435121df7778313542421441c131cee9cc671fad72b2d1bd5748e6aed813e80f75ed6497522f75f1351ca859a922d1c122fcbd532c82d2a4853a1fb2ec698113421b5d6fc9dd429408c90051f8fab28f03cd7a86c61aefb1b1a833676a33df8ec52b3f697189db992758dfd580115f27596d43332bb625f4cfd5bd5e5545238aa31cc9b706d921f4d8b9184573b9249e3aa6d1d182d86c9a6de8f9b26b71d76d67cdd3638f2c48ade2b47dd60a95d119992c232a14ef05e053601c2a178647da59ad43eb5a4be732e1b8792d8a1d7d9259629ad7f882120b8f4f6984ab464183796bf5980d05bf32d85f61421ca4ff3dfd9c94c5dd3b1b33a0e3b113ab1dda8b2e6fe0daf32f72164a940c9dbbd9db8d460ea919e3f8338257f77ef3e884eb3254b5f60a92e0913d741acf9c173e92e3c0da33af70020649c004845c03018531c5394b3a53668b81eb539981c310270a3c7c4ec25567955eba73d9c37af67abab999f2bce0e14e19e835bda0cc7f5c58851fc4079f704ff8575d44e161f954e835e39ad1c5f9e2a414f890fbbdbfd1a50a1c73fd72ac36e4c2668ffbec8311c76a94340edca158d1acc2c0ea90042149a5b5d198081833bc3f1309fbb7cdf34de6e5dea2b04452f18f8714095ea9c9ab37aa003337a5c5c44a315d77ac8f7e35983106ac5ccee6c21534b87fcc7969e25caf720a6eb4b63cce609aaeec0dc0592340efb93ab426320bc035cfd5901f2ddb66c64b1198d80e619cc73ce127e86ddc9df078d3c71671333c7dad2f0089c65e83070efb0161a3014706337436131cc54e43f0e3484bf24661897bdfc34e64af6d49328f763c164c39e9041cdd3ddf43b1178869d9e4cdebd8e1592acd581a5402f3482c6ae63b34246592a35e9e220055f93c06f704b6484fb7f1b2eb0cc5e587cfa4d4dee683c3d412f4593873ba2191a218d5aadad29d7bea522307be7979158ab102f3e04329846f02793b775c271e7ab66c1d8582e53a2496a438188fde722c48e7f6bb6e91000b05c1553407622bfa2a9fb146dc169b163130baf7802ecbdf0bd059f32bd1a4549fefc9a3a03a99449c9cdbbd45206244fbd9792a69036e8eea32d82ac89694b65887a48308314c0efbf408c689d119ad46ed237c74c322407cc8d499c49bc454dd090802ffc33eff180ca0b3968b39e0df7f8b259cbe95b754ada17686e1530b0a702bca93b1ca42529d68000fd58013c59ca9ff207c4a2d57122e6c374b0c8125176b534bff226a91d7bbea935a07f8602c06eea81ed5ed388524c7a3fbe0dd4c850687652dae368a48bc8ce91711ced188b7da9a7ef1e7d8b96145b39faf8b2e95376cbd173bdeda632b792296dff0df80d4cb3e30fba1960cffb3492159938e0b61a632966284666f50223e3cd14bfb4cc1e95a707677d0ec770751860411b7fe90f4e2c078c11298ba2010c7410594b9de7e6fbe80aea2cb76f8be0c0572defb9d58cceb06dc1c84e197f867452e6a502bb7e0c18d5b1ec9004315563750ccefca4fb65aa1a51aa32773d6519281b7bf6ba826be6f5403b549c3e3646ddff159376c534fcc1e7e339af2ade2e992949d6f2d6362e1c26c70e60ae9669a3a73702afe1c06684794e75966612e9d99cbc7db18acb4a3f37baa1ede7bc419cf655499dac0d126ac3ba833e4aa4822c7bf2c49ed8d94b28055168f4ac738c042b6f21b4dd779539fdd4013688d933c2502cdaed2b4360fcef5c8173ef2c1f5a91604850ec2c81e706d1a2b0c87154380186b812304dcaa7363afe5cb6a52ed235690d746f1a070445fe4ab9a18df19f0d1e87b1a2e9bff724f6c77e2cbaac74a7694366f16620cf4a1d73e3fac311750c406c3fe6c5df4fa5d996d92673571550d694b47b69383e6251171010e3ac21f01f12fe2c764374a3457f34e83ec0c9e87f182f84bf72f1595714c8825a720545a865f223cc3863cb5631c8224bbbf3e082b2c07da33a0b180acb89db94127dbe3c060ef10a8b32298c153aafb1870464eba5414846330f5f274bb6b87e4a2613549853578b7024a249351fc54079737859c559ee066d6186ef6a06a94c19318ae8fd119998b8b8fba2990970a73ace570ae0dfd6a4976c7e240bc1224a410289793d0a97a71b6c60143b2f0163c69cdae4c7dacc707eec9d2de6820b47a6a900aec39f0157e729eece517ce5d1079f88811c6bd1647d32b1375eadd5bcd5b8ef6e9e05b79f4e9fb2497c2d0b1e886ef68b298af6421a7b527357a3cf8a10963d5503a0ed1355ad8e003abd987fb9fe9e26d919ffece2fd1f00fc87188e2a1fd0cfd122c58fab58ba37a61312c68f641908df7043b1b65fe52707eedce969a8a8dd245eb4694e9d01673b1e441d81609b0a91c4ae4f779c7b1838386632fcb1f1dc90d74a3920741c4c0c3ed4ca4b61a0b12195bc5e16f7ea637a38e63f52d0aeb3e4865d1650a2cebe2c14c5a4c2a155975755d0cdd2e65f9ea0dcbde187cad3a88544e0d9b4a4900a590d5a44ab0121ae1f4ac2eb65b5eda140899d5fa527deb95ca4176769f96a68ad3c506723860b0146eaa4360b738ceaf67292a88f4c15f5c91183fab11fa57427a87ccfb1b4214b44c0d2c6d9668e4abe6e4c43934934eb5c621d5b097508411896b343eab7a5acab87607386f907608f6bd3de45fa08183e01037f339cb3905fa8bdd791b8e7d9ee54fc2e424a1537f63e48ad2420d219b14c7025e7d32c0292867d30c023d3900e6aad9c768826c86467b1ebc2ef86774427eb433785f7b5d05db05b056195824d3e40bc2785e40250206fb1680814835100fe5a77ba4cc5816a80b1edd12ee960fc9fc898cc6051d625206d1663c4aad291b5a8b6f9aab95a0e60e9f12f3693f46958ef0fc5ec460d4a5121469a59ebc1b20742c238592976434be70e9406aa2900d31d637dc65fd2de61a80021c54f7dcf90aba4912a73a20038a951127348621ff65add2a75feea07162e63b10021ae0dc0278bcbb2968e8f6f2fa99216a614adcd38433b32b5481ff35082e6f19f002060b1d489bb9b3ee9f5670890d8bf329bdb906955ca9c9b1e23190c4af9b9251320f59505121fcf1a53150766b2b65e55e2b36cc7fc61da94746b17a9b7f97df86e2076dcbe98ccffeac440de898fafa058b7501b07691431b6d32ada652102d55b2820974a8dbc563de8510d65da16fdf79575b59fd2a490177a7f5bd63ff03d48a554201a31ebd30e8c013223a76725afe3d50caa5e1025925a4c03d19dffb17f5d175320e1bf7f439ea8079322a86024e1253cd71604d458c67e09929fe89394402d165020be0d25d6e004b1f86d249a8b4b9e06b5619d165c2057aec4c4bee1a0ee4eb240217beb32c9e29f2dee1bab88aa620d7ed7a7dae80d04f03c1c17ca78e1a9c803b70020b7b27036b274dd398eacccf27a1f8d67fdb3bba2819c5ef0aa94b7c3995464a220487edd3892385c68e0765cf86ac7379a6ba506c3d687615dfd1664a61e0df10620f6e44766be42266c3202569865c8341a8b4a9445769ba336cfacd7b8141f9a9a21f1e28f7a220f0caf78a9ce7a4524d87fb1a8cdccfe6dec364d94ebbba6dd93b2002115b207a64913e0303ec3915a67279f85002410dc25184f06a03b9177f3134695002022c96e73a8fbe87b7755f8f2181f91b5d5348bc861fd6ab35ee71b4ddc5d8a1f210837a3775e5e598150999a4706ec22526e8321f73f7e78d0693595aead84128900219538d13c754a2ac0f1ebbc737d7bd3a4468b7e91636f10bbd980d8253ba5f3a70021182188329afd23ba2916e46880a016b493538ee3ffc4438488fcf6a36d78e48900205add8ddabdab1dbbefb5c2439ff789e158197076ab6b8d99ab37ec4d23e0151f20c876fb7a9976e7b0e8b4fa2d40a26a1f88b5203a992c71f86c863f64409a6c9420ba46a5a64b38094cce0e477fcf526a371f81d98758305173ff85e5af9e9d713520a29a3995c535d10d2de254ce8dc6fdb52a0e6965d5faeec07548aa6b43a91159217f1341316ff39e8dfaffd537063c130f3dd19770d2b911eb407f1c05b42e398e0020d2b3667ad2def5e59fc37b22e196fbda8d2c41b886be1f3cbef4ba78e7fb1b18201d0e660c8294d3550ea90d2e976f0263209275ba6e277ccbec9daba6d361286c2028c77b1955f5cefdec1e35cc2e9121d07651200e90184d7cf32f40dc73432c41217769836ae0d553d95a53b045352d122ac2c489cfb66a172346a3de53801ca99e002094543d995a9f86fb5f49c78fa23d0868faeb3bcca002fd7604fbf81f38c44a712137ffe7281b281b17aa5419276642b8e69eb1b1eabe30ebbefebb022c21f268a300208092e548789ae3e160dbbcc8ad981f80804d9e485003a6c688fdecaeb277b500202d5b57d5d18194fea324bb7c742151f84f9fa7fdb69fac77ed936a56c80cdb5520015264325a4159703b2d38af540c0e680ae700f3b9bf3c069a80696bd322a95521ac7f435a2907331d8dc15dd9dc945807e3ee5ab5295bd574483300431612edbc00209836c6b63d43ee695f135717c85358663d39944bab412134cfd66db5762c9a442145dbfb1f0d944e7f7b6d4b3648659b3b12a4a2c53bd72f9f65e7198957db9f8a0021f8fa17536fd1f70702678ded21a1c7035ca8f088961c04af7c7a4a5df96f0ea60020b7b386e088b3bbb85f1840ff606079ac9ea7f9d0beb62f5c7c5a924913df2c4a20a977ef35ba6c8f89af4b16d5903a1f0d005982c2826797c6fddd0cd2bca1b94c205c0b1932340551606bc9e2602bbfaf633de59ad8fcfe19c4050dac8c664937312028b3e3013ab25c7815169231b9b724e8ae2ca3bdb5fd17487d1fa39046cb77482053b98d7674de0cbba37c37751a7adfbc9c0cbf1b40752921a7d91b08e584fe35205372487e10cc1f1e2d524bb76bc4422d97602f7893c62d28ddae4fc9a896d0372067c5cd6065fa02b76a852744f9cf0b97d32a14ae4cafc94a52087f726693e13921963c3293684a500a48267f5579e77eda8f877d15e4911936d0f8e74b4d38d98700208be980fa8c412eedc13df4b6231e3d2b564296825f490db1e2eac607a355113720397b07219a89c803defc3fc3ac5fc258c8b54b39f53184ee13242feb50a0c62420223706f83565ebce2acd2c18f4cfa79edaf67508da1d2472bfe325e5f20cde47213dee7fcac92a23e8c1c1325e6f086d1c8cd27e47535899399c6e1e4f8784f0bb002014a117fbf97976c0c7af3a56308a4dd19abf6f6a7afb4238e5cd2b41ff3d8b5321bd2e9d0964bab0a1e554eb0a1b350928f2810c4fcdab5ab4e875005cb4a9e69700200e9fee09a4bce859bb38e62a7c74941cb0376d118f1738f06b8a517fb618ec7a20f90381d08f1fa4eca24bccd2e979d0f28710375da371378f74f991439ae08132200b7166584e050832e699ec020e5ae55f07fe8ae4ba7c2c399ef302fb1abf064320eaf6573fcce33c66ea0aab58eef64a3efc1f637b738ee51a95b162eaa9cc476a20e6540cd1e230afdb93aebba474c269c423facf47f2bd500e08961f7c0a4af55320a8fe890159adee60472ed604e73b725c36b2e0a1dc9dc94138a95ab43b38152920d80414b480db1b23a83530d76b6ba4768b612856f328c5d1f481c392bd69f670205cbf3bf512e6647b24098affecb63045ba48ee161913cbea137d89f8c2317e18213ca2d715f1dbf2f7d1cd1843584cee3c6cb663830c2566d2375a8b7d4306a7b3002067451e9fa32a3f67f8940b3d5ed7356e532ab64588a30bc64e68bf0f1754eb6921aab661ffb9e2489a080b5dadf8b66a01b4da585f1d60fb19803d7870aab59f94002009a42d8c17bc201a7683473c104361db25afd272558b431c7205c1ad60e5275720b5259493fe51d34e9e9f13cd027324de99208f62fc7088503d065bbd22eb671e20c2a1ce148baeb48bc4074806162c5081bbc4636a01d2947e2e511a8e23f05010217f78c01cf3b1de88c2b5efe4f1a44da7b7ad1d70de3a9ee75de52c21f5d9dda10021b1e53880cf898cc3304af3330d0dc20424ccefb35751124b925e132d89ffbb840021ddada2ffe00b2b281c447b8d03562bdfaad7248bfd3b82ac74178258c17f629700209f39211210bbb04910304087e2907c3a8a12ed4142aaa866b6916b3f17d1c3232113567eae2f96882409da14e61072dc3941a7592b816b25b3d52f3ccf8ba5499500217e8262ee95708b1b40ea9644b6307ff3886fa0159a3e28d6155e3c4f737e2a9000208a5f96711ed5da026ea1c3e40ec96a8c5860e871ca599c9ea740e3ceaab2480720cb25ebb06c94ad22dc7d529f3296366d4f65781e165de8cab751d4fe464da97220269dcf06c230675b405eec3bd7b1e99d9191242bb0d8f089d31f5d41d61e4768214635add2924737b775e5c252b8ec10a1d072ac4ab941d8745ace8db5ca7c72a4002025a5b0916a1b7e739c6926915cbdba2f4fa3b8b2728a7030cca4946362e3e84d20c5b5d7283e047fd80f2281445463424ac2a6f1aaf2053bbc3c136254bbade21850801b49c2a7eeee02072a84d52810a6e308b5b895f082b83827d566722f46f9dcadcc7437e6a5df1f12cbb56bf34473a0bbd93b18b130a8a3b98a08ff3212094ecf6309aab5bb96fc39e51df0828b70ed423b3ea325d175f412bea1f96c89ae4459987ad12891d24e968ddfa4f4c00e2fd4ee2d08d2c0e6ad48129c32fa7bc99c3681e3f7996a1b93387a10520949c62c2c64a0ec1889c5eb5c1313291a78dd7213244c21eb9a9da1b77c9ea77880305bccd24ffbbad2883c52dc411485b64a291bcc1440f9eba8277d0d8db1ebc00f874f52b126e99fa1d1ea5174c2556085a46a0223466cdbc23a9e217afdd1de8a60be75e11faeda6091a37299745789b6ade6800081d69088cb8d7bb502ead5f1955391de9d7fdb577fb2da28195a81f6902612316ac9f15ae160b2977310cee6660ecdac2fe9f801f9188635c83ae12a89e3aaab5ac05d3b988fb6854f17faa24d0dd9d29d79489ce3d453903f951a6c83bd4c5874482dc6b0e4883ffa65e4a955c45f7fe7ef32f5ec034595c8216cbc62393ea19900818b43280d3245ce70caa22225803eb986dc3353c37d798f84761ef12a56e00ac6dcfb4350a8e6f108b0f10a1975d0e47508730903e94a2ee8d9f36561d1fd2802bcc103367e15e325eec1cb09c86f40d632e9bbde8b2f6006b4981fed1772729c17d1cf3859e4cdefe9246ff6f6285450b520180f04665c25527cfc85da4596bf00804399c22b05bd36cc68e8e7b5c2625bc34806eed211d86887cd37742f1108acf1f06278eb9028eee4673e0cadd2a5e1f5f257422afb0fcc199e65728ccd12fe689ba03b50dde3957bb674b01baad178efa863bcd10de5235f3fbac3062933488e9b4a60b2cb716c5c2a9648aeba59eb3e50ae3be842336355c36231630a918900fd0001c22157003bf3613cb4e60bc0842ef72d03c3927ace3e35f79e7975e6d93593c1727ece0f9734776e3fd8354869dd0c2e36d992e493524f97875a5798e45ad8800d288ff3c5ed1c656298547b3f386690d20d323daa40d684b557ffdc2fd64c2f3f71938ffad426211d4e0fa1ab71bf2eab2095a61868ad51bc622506f95d2186870b9fd55fadcab4734a96bb996948339408559f1ab3d0793b6ff3830c22dcf8387590bfee93005b5baf5890bf9e3c925d40906e714205aeddb42376eda4f4ac7d96bf9a74546ca377bece79b690d870a560c3b1c4416b06bcfba6904392ba19214fe91184b7545019fb8a5c65e0a6919720dd962c91f98992177eeaec4665b6fd000152c7953810a6139a35d9ab44951eacb6d7f88b6a2d0fd1a05cf109d8f9b8092d1e970d6ef12cbfd2f8f901baae01d8830b8cd521e63300bbc1bc623fa5c0e48017333a631d42b0e71d1508e7b8dcc53fb304d4480e2a4e440c9e53204482c72d97b4d8561306d64030846c9027bf218567d607c4a2304df183036f1861fed60942ba64961824b80fd8a828499888f80a11cf91ad2fe187aae73605bff8a4b004a2738d56a5abf11f9b82f8ddd501443545bb4aeb49fe39b64c7a768380892c6f00f8cfb49f4594e1c88ceec1125a3b70e890150dace647307c1cfe715642756d5d2f6c28218274ca5668a3c2a4af4b79e70af8b83de56337b841c94dcef0c89ffd000109c0d936c59e7b389dd374956c37ab4b8978cf0aa5b7050dc50510f381eabac6fa2e91934b72e798eae1f6f43be168ce1ef600e7b9bd1bbc2c2c963cc1c777c41ea9ce7cb85dbb140bb01cd90bef6298783d8c6c056955eb83b7b9df63ba4b9cb4201cfc83897e54e269398be9aea1e293fe7131f92d22b1fe8ecaa22cd934980f4f0e1b8a91dbfbec640010d91623780a2e7647391eadd5a10bedc3efbbdbf189c33057605f1cfee70d8ced664531535abace6d63bcbcd12774d461c91e4c836a9534b35a735f211cfa324d74febc41fc9ea3e6e953ef555deb6ad348e35ef3be21e32d2546006d43765fb7275d10c47618d109fe806a4f94fc67940ee02aabfd00017585f2e215c1912e88aad895e87882da714c625143b5f1a9ecb9764ef1e1a1e654c08c70a2208e371f9c4b2aca734bca273072eb9cf5621c73ed442efc85624c10b0564c96f488cd5ed697fb7ea414c8f49f5668c4b41227cd57df071e004675cfe16914c9e3e018ac7e0b720b9cb9496f2d0e176ab2d611ede6e80ef5803566bf698e09b80a81c0eebe58ecda39093f0c1651fff5aff860c4b2e70460bb95da3a74cae7e26139d1b257ed9aae65dd4d86e240f07ea77f1691be722bf9855ffa759afac8a1e6c91326da71a1120092a914507c2000a167966a74c8e5fa8533078be90087d59fa75405168d72126667458525b6406849bc1bc9a97db49a37d084fd000154e8d56136b6dab9d5fadb3065668dab000eda7d2a8c47b342ee5c95281fa8e2fcebcad5a0943f2ddbc46390eac974b4b27ab9da4fb3747917c22305d3ad91b5694b312dfeb392b55df60cc8d4f6950bfbf4d5dbccee860d9997d2de34bba2335733909110bb273c2e36c15315fb79a93d1bffe33c358e2da4c238e8ae734fda09936a758f0713f720bc556381e41f76c29b7a02bd44926d5b2a7d818c788315c253a90a03b9194fbd581603e03a34bb298d8a6f4021b887ce813f3cccc17a2a7f6bdc5b50a681723890250c4e9050694c9a66fc587187973f209c1962bbc5bc7ff64fed7d6a171981b814a80a1cf3123a8dc622008cfac1baebce0dfbe52ab080209b50f0445fcf9488fe4818e96d8556331f20c211c60e07f1f80e3ab23103281a07c5df8d85c6fa1767aa997ab2dc3bbbf1533ffa8729bc02f6ed3d9ec12441576ea311a1e30af774c92f70f5d4521b1a67d0b7c1571c45e23785e70bcbbae1da98f8e2eeccbc67ec771a30b68e37f8a385820bfdfc7af405bd5375df20557cfd000167f18545407c8f34edfe760a91ca58479b4caaa3964af57568e4fe511cdc94e99919ac76e43c423dd4024457896c2367cb62da0ebe7d8b98cf79981256d870421ef6adafa61bdd61a9fa752a3102bdbe90ec1f9ea1402c855c2a78c5c09ee8a4297dd815aba0b346eb3be92a04301c33c83b0d02ea26a4eebbfba0b71667354bd8e6c825eda303b05207062b3b909397026f469a3dba5dbb851bd28500322b2b898efba194e9c89a97e378691c6c3587f7fe4bf1a3c69d31fb9195ea9ad626406f33bb39e8083452035038c1714d2753ffd79f62643057bff804d7693e014a80a9e32d1db6e9219e55ae5d59ca7f9615b252132a559ae8f0ea9bb70947170fd3806fe1ed3b59b8df259900cb793dd2f745658cb2cae325e988ae4259bf3674b40d952737b874531487ad58a38fd6d01435d4e14a87b0fef4ba40a2c985cc62ea2d3b6c97453c8bfc61ded2026a403939615b94db3ed5388adf92480ebe647d9c209541b9ab97a67f8afa8ba2ddc4f6621eac975806f7a2935a4754ca1281407254fd0001ca584567228a05aff314a6bb8db5d77c64cdc98049e4fc4e8a0dd02e94d65e494a83fd0573bf26071fdfce8455a8586bedcc9ec3912fb93c28c97c76fd2bd8cf7c77eb032f1b3d5f18cacb4d6e46d1d636e5423de333171621ac4ddf00cd140c8a31cfe6e1720b702f5977426ba0f341c5c121fa41e5f9cf72c676d7d8840760047baeef41a85ee0f58650fffa0dfcf4a354b4fd635f65d533afdf68682c062fa1ef3ed0345e0e6a4a03b2dd3fb6c1918fd4c6ea2e88efc1223bf72d33a12ec9f10212abd8e0d323fefe127edc909daf018a59e7be84b92ad9506be6cc080fdaccba9d0e6153e49ebe546afa2c3a5fd37294b035eaeb5a46ffb1020a5fe683b0810df474b799476566c1a4287bbd112cf2fcedd1be2cdb8707c55db9af086106f66a061f2f39e2ea3bfd1cbc18dcc049d9011336b2bcc240f731b3f45955e15d228656e7d41424ed16096607d48dfe0e2456d877645b5ea8f006b797958aad495cf7d57408484038b87ec99653b7fdc5b8a1ebf47b7883218bb9cd52eb8a22e49300fd0001310d818741b56832a1a31e3aecde85d578e6bef95e0d3321278f243dcf81ec7e2e6780e1bd4de223b5835f57184ea2c2edab2b870fb13f620b3124f2fc83740c26aa30b917680eb4a61a3d1e455928a6325ca2c330a74f35c659dab9219fc1ad2dc4fac28a8055bbc1acf272e294b21d1c3083c105b107e9ddd14314926a5067dfad3ea37c54ed50a5ac96391dea5fb553fa689d4166b8547b0af7764d22b31deceb9d8b25bd2edda13de0b952e8c062504896af885bd026edb9708bdb23617f0fc68a726432ea1c929262c82bb2be5f1536c6f88d33b308f8c929560caaf74b8fe5f840706e3e0b81bee0e46cdb134867bf8b11655fa204759bdc88d492eeb18093dce3035bac48a26fee7f6f5ac66adf63876ef22300572f528d5f482480e951befca94ed142ce71d311a3000d7895f2d9f688edd34ca44a68a09cfc4b685ef9f5e8a6d75e956e99a6bd01bdc002c94cf87861518df3a5a0713d7ac072254ac1d68de90d6a521348969bc2d59fbbcdef6918c045701421c92ef733b3b7c4a3678026ef6ecbb093dd60690ab9b7d28280a81598793788de0272eb52423c3b5335c844fae3a69374757a3b41e3cb2250e36d5e185eb2e67950782ecc1d31398965fe54f680c52b1806bd764fa2926377fa6f7f909ade7774b8a91a65049bb6862048d389c3536be88e1800ce95c0ef3477fcb5317ac511b78dde2dee12fe305773188132574bb60a5e68118e2373411b35dd42ca882b7b833c4efc20f1f3bab6cc6ff7036d48b2051bae2ac95dda94cc330ec1d0e3e09f856c7e36c44020b01a5076268aa5ac517cb4c9e936f958b7ac6f8fd67e961e083487b2befc7f923c559f6c52309b677fb090a604a6e9454c2461b2fa1574403fc2438fdaa1318c606707c0c600fd000124940c5f2606ccd649a9988afb6775e60891f95b91773924d7017af430cafcbd0484929b049c7a8372852bb695ce1748bdfbc150a5ca6a1519c06e5982c990e6b22f509a606337a647d9d1643522264b5838390e716cc8bd4f47bb8a0de23577b998855752c434efe432595f63529bda7c7164b321304afaa6a4adb71dc05c25f5e5294b69b21c75a13edec9f8c0a31243aa73ce6592f1bbc84c4705daef99acba57280dc92de02e17f1b28473f200b3e4a8e577312e51f1f79c06ea49f9f1a27eef83ed0749d5eb6534f9d8ce773e94f21407cd17154c644d8099b4edbeebf4401601d3e3667c32186ae79c69abb3c72c0e8220b2ab9304d1307a686c9db992808823b2b219c9f81d5a641e40be3eb71e841db1e43d571d3b225b5d811e9a0101b37891b8a962be19c7b127961ac447a847de4782680d3ced69df0c4f032ddf36d9f7da47aebba193b703598c12c2214dd41953a8fd4c2956d261c989d560d09809e6471d71c5ccefb171e1b84b806e1ebb792b40fba818c40a8ccbd07ccd5301fd00011813eadc77aec30750c84dd27a1dd089ec245bb82d93aed9f343f7cacd9cd49a22a4b516df334c6981cf57d9038700ef0eb610a70bc71dfb1f4d74ae3359835b67090bcf46549a2f8eb5e9d9573d6f2900efa6164528cb2298d488b7ba8df39748f6fe41ae04028fe3e171c68cf7954b228e0e54f266f447d9a93ae944517fbc95d02e898ee7f3619a02abed25e78cdfdfd3ee09521a5a1067790117d5641cde06554e7aff909ad7f7d8f67dbf9fe0ebd1f75856dd0face6d53b10230d2f605b1c1b022376c2f569d9849bf094f7b47e5c1aa5f88d3cba904de9fc2299ce60672c59b6b951ec15809a78e2beb4b64db2768a44d253da8268ba4d6b1517b03ba980ea5c2231b957018bfcb1dcecf26ef89a5338976732dbdb6f7354da85b62d1f0064a9c99ffbc36228487ecd45f4d605bb3a2f0113b756f61bd2d5bd7a75019489fb9b4807f90c78004233c53031f7013ff3fbfe9f37cbd657c61e071dc1e48a5c15f5b1cde2ceae555497228b19d2be443ef59e89067504c76df6197e899aa833fd00016741248c9db871fe238a8fd2a153a21d659c82caa6d0d5597a900979d10862ef7bbb9643b8423a704cdfc787bad8b06f693e0279e399125db92681391a88cd1f8a3b9d620fa3d29071d4b540fdda7d24886beff41e9624955bef1eebb27505487c6b650f941c5487db2d6a9de360fac13754bf6bdcacf8f5162e78e1808c2021468b402d2de932a590a22371ae513e4f8385cc3d54d6d8112d30b5053dee2767bd5d68b1cef07c5dbe79be7a13b4dc761b303625a35ffd50cd1a7a7607c34f76b737cad0a77c991efcc0f48ec0baea05350643839073c4d912d7f6d18ef80f1c42320c5a1949abf9500e5e027f84dd326ddc25b796e885f878be522c987a47a1fd0001892880727994f3ef4cc7bc3b009d3ab0ba12e6fbec400d978a7b094fcbd63826e5a2dbbdddb37042f9d488f82e0f6d64bcf88d327aea5615c6445c13424544d45e12007f26b62408c19eba388bcb27a32b549f048cdf1a9df32817a926ab34e130848792f71cb81f0dc000f5b640972a5d1180b3876e2c170e3ef31e27610e5db6cf50077970504de9b6354284bf12106151876524752dc34024d7c353c8618c1a0f54b958edeb421d5d521470bc1edabdd5106b7f89c1a5d52b36c7491d76d6d52c153e17e692a1ad389a57564aaa352fabff8b65dd9b18b6e76feaece6bd76f09a88d60cc344d1865aa7b97dcd9b7ee5d869943a0aca6189289cbdfd9464cdfd0001ba441f650b1c2d89d985d28731ee44502b189fa4ea4e283e9ccc8f5aa2026a3b761d9c83d2823ac20f780b887d2487f900c5f568f2059f44c2014b08d7886be7d6654dd8c760d82a2f4bc80e06760211321638b0c676bfd4254fccb74b497e866d33885345ef0a990aaf150fc2d36ec3a7a2b21b3e10356b6d7ee5984273f34f295ad3c9ba5ec4af588da45a4b512587181a89d3cf11cccbecaa9a590396b63f27ac3e157a08df9aa19867a7729910a02ef994441cb0a733d957c5e41351f8784776b45246159733c6818c817f4b7f219a68c13dd02b0410b037135025fd5a07f320f44a21926c5c243636bbc3a0f437294bc019a8bc8e9e14795ea712ded2d28092f38d55599ff2c0dd2650de43a589a498fba4d1920cf9557aeef01575efb7139b8cf10b6ea5ab3ef9b40d4a90977ab89c55a5af3fbf0f8a72197abc38dc6d6df406cc7531260a8d5e36d3ec1ddb95486596b45977c1559892fe96ee1ec87d54083c8e88fb75590898be9bb956ad1593009b68285fcd60e29a392130fcdecf3680c4f08fc6aed56784e471ad4dd0d0146e0fd41c4e17d1e660daa6fd01634cc52a48fc71402242ad1b9a1a42f254b433769f44f895ec40119b40a9f731e07d5b5a08b65c2ab225a933a92889e4da908332a35de27bcf88db00ae7f7d4fc3270b4fbe1cddd3934864c12b77d6f109704e9c0835742abcc29dde4bcead8b0c0a1a08fd00019cd781470ffc9915bbffff9b297207280aedf02ae6ce1972e2ee4f5959aa022fe22098b388eb542ca03a1af83a0f526eeafc95b192c2695eb74d1e55f6c61a950402deadb11bab08257124b0ee26ee87e87570aa7c615eed73c12862708012e2ad8775444fda2687455a0c2e79d9c87e2c8b6eabcc3622c1bdf14f94747086ef33aeafb3b282b1cfbfe7e20bd20e57a00cce137c764a07a8f25e96cadf0ac678eb79938cc592b38b299d77d3d2dccf1bc3ad3da77d15bade71085176833fccb4f4d813b43fedfea06496c732f0ff2898fb0a13ccd272a51da2c7e2c92c0b47106deb290f12f1927efd87500483efc37b35bcad9aaac18b7676d4356e9080cead811cd4046723cc636a017890c78502f131ed1c4f2bb1c67f5a3095a1f39362b5b1d769e202127eefb4c36b280265946cb8519af11524abbd4d0d35b83d9516983b7053f3c5a583f7616ffdb271612030cb06448ce5aaa264ffa9dab04c64cb246fb188fd20ab61d2e39695d47564fb8485e003a517b2f6267c9147da7051ed4ec6008140c144235a6b9076e23b97a81e347c8a367d9c12e0d775a378337eb3b78647fee80b99107d47307ae73c15dd22a4210f98a5e4b7ff6ca8ea79286ee5acded439f8633ce730ee68947fb12a323854ae232ce75fdfa99d274926b14f81e93279f5ef42cf7abaf5e4db9dab5e1ae0442e9a4816d9df5fd1c671ffc2ff9886c50ca400807db6e16ac5cb6fabb094429a97f7ae57639537b30b12f634196798cde9c00a85fef093cd983ad3f4d9fa8cc2168a8331f07fbac52c2544defd008d5d31905a6b4f57e2b786c091b019dd4a23167a457f2adef68fdd71a4989921698a451c323faa2870b78f555c3c30a56319393d5ba640ee9b0c43a2daa29c5c080b4dabf229814d08f0c3c7e21b9fa04c76c7d6c3f12509c060015fe82ffff8eaed0caf49974478bd49b94e1f710945a0c233577808d97d435f09f9193b1e7abe8aeacd1f6f142c9eec20d7427cb919262b8a81372af0523fd6c219b427477da7715f7f07d48f890b751206819b8693faf2c8f6b1cd42735ec457051022d063446c58e9e23a940081d24e2fc309cf1390ca944179e6dbcd7cb4e39832f3c8cdec876f8d964cddf3c27f87802eafa33b2ef393e59fdf9028f1add7988eee4140257fd5b420273d9bef73715569b0001ec2cbcc13e70f3be6f0dfae99b504ff2fda3a3bb4974ea056e1fa739eea46d38af1f2cf3ebdf32887e7ecbb570a3633c5e2425f29d24a5ef1cf00fd00016100ce85d6fdca9575406f04fbad2d9cb4d7f6fa1393be3c3d6fbd5eaf7a99a02f42b8c373bdd03f7c76a9de409f943519dabf438788e2d96b34b7743af43a0b01bf8a080615013519a4424a5ebc90cfaf822719f1fe59ae708b726307243bf7cc399be43050e8b9115ddc1cf4c2e0c4b2a6a9674b1b45584d7b4ca779eaac889dd720bc46bfda1ba2af747a8e53c2b3b857e1e5b607ea5fb45ecf8980250056134dcdb481bd915bab49031c6ad7e3faaa58e39952119d8729e317e15e864512f0bbcc6898a71cfa619fd5753a5b32bf98dead20f99284042a0a661297d468445d982d9159c44fa344aa2cde29454c7b08f3ff4671f23a4d062ec8508b67dda681c0fcd0346c255f430aea7066ea78b6571e8493274db67d253968f033f88c42a90f40a8298aa3db289f0e5ec038201892272b636d947f6ae9c6342e5f081db2b188a9d3f50b2e8fb396fe189ec082fa63fefdb33d11dc2771fa1fe04b23438cce2bfedac58a1c6b6819cb7b02fed3d74e8fcfb839df07bc474ccd8ee76cdd35bd0081ea358b44b3840116487e3f85d40ccef06d78c631423996e991df95018dba5db3cd0c639c4e76122db272e4a59cba263a26cf5877481b93714bf9d78b019e7493443c73c5af84cb6e9837b6d809da037a573a6b68cfd8bda5d02da0c50743e889afd72406eccbfb255f957ad00fc76a8c117d182363dc9e914dd3d6cad81e32bf00fd000133a5e5ee9bcd22e54c18d8ac925859144d32339b2bd00c32e8f98b754cdc3495143c425da51b3db805aa3884ea27c2ac815e4f5575491bfff800fb5a3c1fc0120fbc33d53ca941115350be844493da6e3dc40847fab916235bdb3409a356b9528278102b4d96e93c27c88a081bf0bfc4462ae6a2e340832ee0c76aab12f192f58b4fb5fe386cf566a762f83bfbb88d1859a10d08ec78b01536c3dcb69e9a441f9d2e947e898dda40f57fce5f0e5184d93935fbde32cdb750c51d57cf7c2be917fa299a01c41f2a1018d435380632735f9ad2e958cefc8837c21172bfe67a574db3d8223eba4d0327850bd5fff38459d4fb6e00715ba7ee5355605ea0de209ac7fd000112ffd4061db7a6cc8477b6145a93f3e6fba20a368be255f4e435dfa8c746ffeda600b87dc3e5fef1ffb6e917b09319853adea9701d795e244c4937d25e0cb0c62bf4f69168aa82ca022f6a28a7b85eb1e8eebbbaab30a61c785e19f59232d273d936e4ce4b9c62ebb3ee2b96e90a5a4ab633e9b3704fc0f50ecc5b9ad6f3843576aae92928ca7c8d09e3b87a6281328a1482bb642005cec7dd57f3ef9b1a167387511eb339412c109bb94b57c4e46e3f33d6fbbdeee42e60ebc9f44f7530b86a94bd9a9acb974c76782e954e295770716cd216b83036fee452fb2f83ef077d673f1126ed412c8d9df216b0cbc72456ec8e2932de9539cfd562ada45a389a4d8f808cc78aed67e86adc2cabd1bdc0b21969cb52b9b1f1a1d808d67d8e8bd478f293d9b81bdd65949f5ea0bcf48c6fd5995ec992a273f6e335e7e6969d008590a961240af5ae24e4410dbe1c6dbd001f77406731348a0dc4ce2f8a683e4fc7a49c659207ca2bd8fdcb31d39e58a8c75f76031d5b65d96f0f1af90da9306f019332558135064b7f64dbea34cd68c039d4dcb703f63a0a1effa946cd1c9bed59a956cf85b358f4db3118070265f8aad6c540b066f29852e005003666316066b324cb037b9b5fb6ab8b2908b1b5509e9e0ece6345cc571c84f83dd0efc128ff27a1b5dadf0a0921544e9490d13fc82df1939382f1b6170e8ca99b38362228da418dd219970081840ec70b20c096e1be5b162d058c5c6db0b672d4e555effed3ec8ca85dd69afaca09d85cc206d179994b6a0443ceb4e65071ac7a64842c8a6b2eb8f89519dac433829865747586af18ed1d8d864b75baed59daf5d08931cf47dd2c802545505c9ae8d351ae00efb35c5725f8d3cfd87c7792b084096af67c7f63bccced4a038d00fd00013449e73edb7c7fbb2df81482f1d22d02eab854f7e7a1362e9a41a95d0a8ab7bb68816dd19776e0cde728b6cca402b4271b384f71053bf501ec8cf40167b76661d11aca1ddf4c47421be72577dff8be5ca3461dc8cfffcd99fbd7accce7ebfe12ac6c4f8d265b1416464f2b94cb93b40957eab9e01bfaa4e33948fb2de3cb093db74e914c3f048eca8699735e3693752fd59dcf48cc92b63594b8595052ca405ac9191c6ad3cf08c6d92c384a8eb0b643b57e8b9f91ad94ba1c7f5d8aeff0baf1a905ae1d722af5d1d4da2e56694a7857f99c114eb63d6914b0ad466b6ff0970457730cf6ebc607b1064ab3e792833de818ce7f47fd212d98dedc8602d90a08b281fc8f5ebc335769ddebdcc8fdb0507d0d814fe95333b151b6518abc1221bb431aa83abaf371451e4ade47142b1c1159b372fc95380aa1697935bda8ac28ef6bab6c69d6871ed0242087f1e69f4ebc71066bac94040f79e5fba35c0bc9085546634d5b1fd7f5c85577fd7b645845ec87623eefaca134432ab7663dc7f5a66f55a80081cdcb09b132c40a0e33f8f9c47fa993a3d3c78f5b4d8b7c0ccd2e343cfd78819fb9d6556e3ad0acf67c85cf5cdd9335665761a091200ce34bd81172e0bc87efdac66ed1d4d849f9b1e94ed2db44601b8f07d85a173a6ed9a76ead0a21d48421a608e17baea8e9a6b319c0c41fc5917263f6c93208f9fad8ae2b2eea2f702a2fb60080f98b3379369444ffa8fc207955e7b01575c7007ed19ec7291f28b93db7aa7148bffb9f98f7e3ec1e1f21568ad37f91c4603d3f276ff0fa7e9ee8ade05c277775c3ca2440d50d427a23f7aba81b8b1b9bf3ea8fcddcc3c3a7708601688fda8a6f41d0cf7b5aa4a03422f332012ede8fa6a9d8093980f07b87092bc6e48d5ab343fd0001840e370e28d6cfc75778bf5612efae6c33b4dea0c4964e810fec77ef1875956edcd2e22139a485fc4515fe44c57149905efd72b16f4b367735c1e91727632507aeece411a472e4f270e0bde542aeab9961d4fd0898b821a6f193dd42391de664331e33b9244e0598669269c73125b21765f048e0c2b9d17aeb0cb3c112a318040ea4126c43e0f46d1d9c1304d95f35b875cc2fc3964970e3602cc51f2c496f108f904e2dcc8113223a9a074c344b42662c3fa22490db6ac63a1b9abf0dbc97feb0f4447eed2e96f33f854f695ce54e24b9ec180cddc752cbe66fd361d874873ea3733a4ad92870b24efbd22e928deecd7d4293b680843d75127c0eec3f07f894fd0001266e0fc8977335a776a66b44c25dde8ac3f40f2d195dd3845a0019b5062043d1e1f24adaaa3052565db20717cdbd769bc1cbad88c0fc6a205fa4acf472f954d161c3450cfa0c622b3dbdbb2813af60d47870299c1f3d793302c678a2d4cc7705ee51b675dea5955fd7ddf184cad643fea3f1a428b6d49da35ae251744cca95446811aa79d4edbb1399734c3b3d71c7ef455eb73f72ff013938ff65ffe3d8cc8ec321328775db8884cdbf9645cedfb4d86bd54cda89c7a4da2da5dda4d4f459518e058d3614a4a9475426c64a16bb206d2a02decb9e301ede48dd0247d36d5b9fa1e449f44f6a3ea7999f31259bc49ea13b62cddc0f877435901da6f9cde2e4ce816565e8a37e36ddc7eb0d1363f2d0641db79c0da0fc7346cbaada28e859cc7bddea3653151df18260a7609aef925c9de21299857732ca58631eafe768ec58752d63a48b65895e428bb4054b8166e61beb6e8127488941601c0ee1092f695ca0fa9d7b965eaaa4dfdbb9fb2127a75447d02f64bd3c4f4e285e781b02d98b21089000fd000118a3777a8b2c2a8e5e9f7fd85ac71b6e843ae5ac31d3e192b73c830a33eaacd7ed6f9d5aed4754b9b6af55e60dd31ced5d74d2910c2e9a500dd3fd8e282136337919b3e8faf81a96315f04588f7a86786b922c6ada489eb90bbb8b1dcc85e8f6c6d3d5fc561f4ee579e9143fa4726cbf168119fa5e2b1539327327f00ab363eb065aed240f5d120096951933dd3e689118d2262e01e5827c120f53f80dc8c7207f2b633aaa0ebd350e65882d7e9069190163975682eaeb8570c6c297b614a72ab6a6c3276f754a7fec8ef86c50ebec46bf88701ce0c3037db989247de5ee7aa731ca30af5bfe9357e3f0de64160b9ccdbb0eee9885478a6e9aab6902227933c8800ae488d64c37f7e8713e92a1ef4da540c5da96b55f67bcc7e5b3a0950e75c0e75edea568a951e213d8713c034245f6d6e307cde14b2d7160bb2ee6628d3ab486d2a0a9c760478b56003f84f6ec15c6ecc44ceabd1d1007cbb891c005f62fce138af30cc236c0e31e0d93be2f94b9f3a7ecbff058bce5fedd4bd294ef8bbaa0588002c6177026c1525bf82d44c5458f84a9105b3f6eda233d83608b024ab3aa3646a9baa480c983df90f90be078d68a80292b8a609180fa075f93e99590018c49e8eda3098caef604bbb643ea3e4a0ce82407a80a13e5f5c786746571e74883e7548d881a9ad516d0b7ae5018ef44a5a93e227057169302cab4666a35b3b22ac678fd0001df3435a26f04bd17de1cf54277d5e8b52d4bd552f2b27115e6c65a252007b889c3d5178dd15259d2216e0e3fdbe065b38b5f638fbd77ade95f13882aeddcf59d508f0252647a0d705ecada91727ca37541f25aa4061a9ca2e3e3dd2169ac00a508db5661b41cd1b626a5d64e9a093b3509d86c122264b53c5fc95d013c6b8d58a9faf515af46d5d41610ab99555c2c3ab363d604fa147b0f1bc86a3da26ad8a4614e6a27f84f58b02b698c232d6a6d864e49d1fc95aac2fca78e1483c53c6344a731ea31261a19bd5b7190d9ccb8ed161d963d4949d17bdb8edf19d1bbd0fa9311b2d5f2b3febc5e4dd6e3c6f2e169ac81a671aaad0723ff8e6b0228ce57ef8e81423a9b6290dbbabe3ab5e8cba4c4e6453766806e946f6261557c2b23c05e7aace181919a12ac324fb26f709ee0eca8b746eeead2b12c13f01c39d5b117bdcdd39fe102d66bccda50456e8994ef9924e22ce634ae801c9cf7ae24d72fd568379bb6650f77af9dbaeacdee02719fffc07ad660fc01b84dd88f35e6f97ee3c977b40080a08b918b6a42c1e2cbf0231135b5a666a371bce41d2b53ccb47dac374dd8a1b9d0469281570672916c4841692a836200f9d4dc3d69b71fbf6a51ab597c6b11ee6d772fdcf02350817e4d85f79437b5aa21ed0407fe9689128f9166bc391ed499357d91b262930834e96b1fa8505ebd15eaf85ff38db7ff5e9897c1ee9f5f883afd000161acbe21f4e6258c082bcca1879e68a20989c95cd5f7fcd1817b807d6819263f1d32cd7882282db7bc2a94b5ad3ff053198fb7d89b51c0df65493652932b4af07023ac9a84793269798b2850d1296aaacde7fe3eac56b88ea9f6656e4576b58ab9eb13dbffa731c6c4a57c2d06fdae1ca33e1fd07851afcc9d5c6021a1e0b3524c72bac8c9ebce52290043b3596e9bd0639220d164fb41017e08c632fa32c798c61c643af591b15148d585dba6baf61948e53d74a42afe3e45505fa249c6b6429cb40108f107983b50c8acf42c9822527413d866f3605812c3665263b0796e7a0c632464c131963eb1b39eb54b6d117fd25fede83fcfdd5bfa3edcee638196ae80213f7ede12505476e213d56a89224818d5764679824cfb61f0365494e5a4f26901aec701421c83145e75008a4472d66f06ebb3af51b886a2325ec482969d59a86dfadd0073596b1d47545699252a94475324ac07619c4b2664ee6f3b83dfe1c7ef6ece6a73f1040016d887adbe9d8e076de14815f8ad6bbf89d82b11c8de622d8094d122e7cc525f099280b1d3bf5557fae729b8b4287fa3f8a92623ac0cd62ab57a10f3fcab1493385a909c09e80ae7f7f0d8bc7459ee95930455412ddada18aa6bf8f8d98e5b7402605066cb4b6b5ff5cab305771cec6fd000032a289a8722d0a402afc66696798c6106be690d05ada3add2ce5947851c79dbfd323dfeb194718127e1ab87badfedea9cf54d4cccdbabf719b5d4af7f3697343b59974b5e263687aa60953df4a207784484d8867fef51eee561964b4b085ba35e822c22bf33e7fe10480f5a63666a5c654c350390521cc06b63b9b215ab1626ab9bbb8fccfd1076140cc362ef54ac515a2c924781db8d102e9b8b64149ebdd98bb102cbe31305ba0081c572ecc50846a7dd2e812f5965e3b12f571dc933b0bbc7492e8d4a593191e01ff9895d3807afded49d76ebc51e0b2b0072cf3baadea00568925088a1a6b1d5c659fda5a6a9af46329b067f5ef1f80b2b5ac6bce86c909f96801fae3f620b9435f49621a88f47da90ca9d7c8bb3d52a01897b92f78f60c64c9b7d9b2e7a1503a000fd00012328fc8b7f3b07c470ed2a03147afb9dc212ab82e70241794f999e711ecfdc51da0413b06167d6873a91a8335a5b2df0cf067681355bce28f19f5fe2a5c74dbc334d077ceca07c981b94c89f5b6aaa03b70fa4a2691be52d76461356320efb66d1d30a771132b6dba09c35de3ac8d46e9894a44a5e3757f17d2c218962af03aeec834380e2137479343535fd8fce9f21cdc55b3ba54a8830892a1afe865c39804811be558a668764eb6c206306132bbec65d21de4da92d9da947e28bf1fd72dbb246f3d5a32bdb7c2c4c6554c80513858b8b863f66f90df7b84299ab692b4f638beced0e40179b25ca27cadae375e6494298f1af1a9c6c8ca4c27012bd5a578dfd000100a3219c72f3226a3dddbd68ee35facb6b68f03dd8224e56e3f09fb6c1e8a5828471890b83eacb0acb587e1dcada3daa1bcd82e86588a3bc4258cad212f965ab7b7b67f1a201c3fb65fea00edb539524c439bd574a52795c55307dd5c69303e9acbdd82d227a866708e391d39f30a450876879e9e96e1166af40843d022a98b6fed4a3a75768ba478c8c51937b0feb9e714901bfb03625225c8f6079fefef5116d58da16284e292538496390666b292add9794937abb940b69efc3689de9a1d881128212f3da736d62a629ee1b5f0d0b9f14f68619352a190694a31d7cbead5c6e88e92882d58ea2b4b2d7fbd9a21abc42fa72b751c30f4d1dcd2b1d538a9ac580ad45380cf5586d1862e7b5cc1f5406e07ed7c9f8e2d60b51fe478c8cb1d0419eb74699935bda7fd39e1bdf588eada0a34c61b50952c015c3348b140b862124143c5a6bff8a95d55e96af5282d0d6e75de6b4d018cbe8c314e0e6ddc58b3a9769629e7b668119695c7454a83e476dcde3e823545911058b4a3c63e34d1045296dfd0001a2656e2e929437093f84eb1f5bb7128ec028b07421266121318b060f0de4cc8a979142cb6d18ea76af9e768249b046b79e6c134300c686c706266386af960f5139cf50208344458dedf8427753aa31c102e5a9c27fded58c1be68b6eeb1e7486bf7ae4089f189ab4b2f4cea1390749082ad37909c56bbd385a3d0ca67777c50c31b60a9055f4655ea679c3fe517e9c230a83beb74707d7d3237343972259908c0844814e73c838a8f476238a764c89fdb7dcb41ea693d81e4dd679f51cc7563e0b317336eadf517a3835e2d23ced4b898a4263a37ba4c40655240d10cdfbd608e59a960b3bad9f86945c4123ae65fee8cd0b11b8b3e4ce9186fe40a0d184a7b2814d798b5e305448d9efd326751ac2b5135e377165ba43f41dce4d20a469df4d161006f30ebdf5964229057ea556cc9c94da42385223d8d4a9a68c98452eb4eed7efc9ac9aec7e115e6f0ee4dd8a59f0c3af9025c27263a0493704548204fcc9d9100493fab6bef47752ed0c197c7efec06868d5be4c6a7085e44142d0681f9cc600fd00015fbdb2b09918819b56dda812b5213c0b2ad01cf668900c07402e80b28de1286dc3d073e305f0ab61ce03c3b5dc6661e8607985e0db2494ef615d15200cd441a409d6b579352cf40d8afcb386f2d2e6b193a1d4107ec1dc395c5fa542518356a5d2e60281396b45373a88655a899f0440964b6897e59bc6e4975ca80cfab2329bb4ab9e64d55146acbae756fa01de037ba67f1b32c1e3bbe3c897d442d91f2fcec723eb41ad81ce587f89362480642981290750dacf2d89ca70b317520c13d535438e7b3baab43f0f9470750b3bbae829dda95da000ecffe01a4c78d0c62c15218a88a31afc3ef3f8af914c5975cd9d279491c849265822ee1acc28384e5fe0bf8037b0d912da535403f5b695565c69b8b0354f5ef2a7984ede9b647416cdac8e9693dbb793a8dd01624515b5839d8c9df43185e5a534b02cbf5211a5a76901a5f290d235514f7ede4c384033c438aeb13e916b0b3808af47a069d98c7558bb411464d4f6f47b9a27b0c108ee0105accc00990c74dca25dc8670406c9515b6f381d819ee591e0d496a46333f86fee821a2a99c57f033ddf7f5a5ae2ccad12fd802e2466086269dcd30d5bf166d0e75e78ffdb17314b500eecced0eb89fca7b80e7b072c1995b83aed72d5b033c20b31eff559d64541d17a97875da9075fbc3f2ba535b2cef3b7162a395d150634cab96a316c7c53d4ea11314a33afe53001e41ee3c50081cbf279e81c999aeb762f1ac725354d6e4b1fb7c81630dbeff0b04745dc66616ae25a2454e0f0360bb18079c1c76ad458ae27b904e5b878056d0e66ff205eacbecf2abc3742639611e2b638500423dd50fbc25f9972c1e4e9c2d0e84cd048e6c7cb22b11b3a2ff56cea64b7bd061ecc395a4d56c24be981db2975c234be0b2f81008005b631efbb2fa8782f143b7de38c0de7fd23c704c474c76ae0d36e8995db201572252634de4493f1e88f9abe2635e004a84d27294256a6515b003869890d730d1b9201d75b3fcbdbe0732d4c14543d1be2ecfc827546b7be930d1028972715e99693f7daa4a8a240248d07912d9fdeb64ab0683ea51c2f4d3dcc6787bae732528012df83c70e6e3600f7e891987318ad29b9289ea6b2e2bd82ace318eaa84c2fd8a490b6d34bbe6a75655a09a29835a26a53cb859c9930793f1ec0e3b178c58bb860145c5291f5cbed4c3eb998ac512c603964527e1e5a0f5356ed48b3d0a73adec895df93a5ef9284231cfa621ff60b936bebf4aef2a744a0fb7d47ee5f43085e802f80d547703739c5c00e8dc4a72cf3995aab570f821f775544c3901f2dce970d1b4129e4c28b57e8ff266329e0a37b6931fde5e5c54922291fd305a948afbb18f9effd5d0f60a60d35979ea239b80e249ef70b39f36ee312a4167e0aec0aaf23bdfcd7c53d64249c6351a87f066f8f9845901b7f757a05e0a463133e4df6571981a2c26bd1cbcde2cd0690917112f976ecd8be98df9d99fe49f98a11b45f5eabacda1ab4451ef9cd3d1abde3ab3961a3ad111786470e3ae064b5d3b5a16576834cd64ecab08e6749aa502ff3b7057b7ff751c234ec9b08ea5e4e0b55604114ce837b6718e2ebdd73d1b4cdd2307ed0f1a6123f4e3433d3c3fac7f08a89975ee59d00fd00019612c87cccbc8ece8380fb088fe8852362b7702d24848e60213f33efe5ef71b6d76dd868998e2533cb85964221e103dad6049dcaea215a58610ea64eb88bcf4570a5cec9c88ceaf735f4d77d676dd6cd2abdc1c2a9d5e8d625927294a464fabc08ed5a769e640320af871b6c10c0b36ca09c398de5de8932120719d2e71c481536250d2eac410ab88c4970c68b996f15e4fcbd4090ff80f8677d55a8eb1274436ead2f15cd0f0a141d0706fcb3eab92392ccaaa36bd7f4eb816062bf3539a4b5483191667b1db7d13b5d043ff2c4a105cbf74a5f8450e16ef9e56c521865117cee88c49480177b5507d2916af69654cf760c5c5ec886878c27c2d3c8188072c3fd0001dacf4c63a866d5c2bd21ecd5441afaa3767666957dede948dec007df174f22cd53c05bc5cd2a36ca827fcb8446cbbed912759302cd005ee6a2c69454a3477db36bfac8c9cb9c1bfa913cf4c43f72ee57ad9a4c9c8f5551af8385d855226e2f18a55291dfcc1ae5d60b8a79d840cc539b926be1983c2f16067b104e2c63d8253646c89d31ab4c50f166105a04c19a10ff75cb467ee83717ed391c72464a7fb3772eecc36434bf946de0d03a4d4a5de6c4bcf8ef8643322167d73b424828c43a14dd635cd9db1313e4d4ce5254746fe90672606a0ae15ee5dd388732ccc0d3a4aa489480e6ddf7e6e0556d469166a96181cc439500ee7823f496be42c2950808a4810f484d78b6727e793fb2bd80a89e39bd21df6fb17584aeacb871ddc341cc6b7d10e0a476619f4a1380db2776c4a166acb9109a9a4e3e0d1c722b1cfefc135947ad866938137a8d8af919f4010e380f0d4bfd086059207e5a6155a66325355282a7453cc87c698fa58dc8c3575cd81649ba8ec17ac529ca74957e55b9f4bb56bf00815b9bd44f0df8280a75a1c07da606fbaaa180fd975cf0fb680960c3c3e6574f5db9ee72aca6974ab6cd40306bfbaef658a3613045af4e85edb81331919d3b9fbaf742fd7873c20c7ea1460abf68aeb1af6f3cd8efa6e8c072220c3c5aa34f650d1d946e400bc038be346c92d63a2160048aae8acfd9d14b707333ebe2264080b700fd0001d720e0212231342c3102b8025ed9be48f49d2e97999df5268fe485db4b86020262c62548456cea96a3aaae658ec4fd624269d96faa62933bf6da9f251553dc3085f61ef29fa80e09d651423fdb6feb6ec0fbd5a0e5cb370aec10f239d2858111df0053151f4697104af362d12224e069e0efe3d23f3580fa7dbdbbe2021882b7236e1cb6e589c30427f5afcb414b96b5223fef25d08efe1cc1b94727d05abd7b5fd828d8be4658e0f4467314f3623fbce8484fb58258fffbb0dcd775c78eca1ba6d0a125412db9915de16e6a7bfc80ce05108d8a1a29ffad260498f3d7d505a729b6d80f78cd7c84176c02fd37188433a8481b1ccd90239fd7e7041e2e9bacc880eb7e639dfa8733cb68a4cfabfbd894e153b67e939cdd425a17ef4cdb3c77b204dce1479bc7205b856461de37523dfbf9ea529fec53f78e737ae178bdfc1ccf9eebb12eb9fe4dba614698043db45384cc80ea339e16b8ddca87fb472f2a9ccb2b5152cf06306e3fa48824e5a3fd37e9552feeb586f426737baa0cd20b922235638096033eedc83083b149b489f7cc907ebbaf77e0a7145e5aabe02dc425aee3602ee5a8e33a0af6f84cf8acdc541c7a1626974b21a88088e9f42fa879852ecbdae5df0c7be841051f73c86d5e9af4296da7b84756afe65101ad68ce8150a4f44894c9c3883c7db6b9a8cd7080842e712d37180485ab416d4e4a229270337f4c68491f5457ec9b3b6d4ffdd935903f8e4c036db95e032911714af527430baf622582e1fd97f6581822672800bba9ea246ea960aa19bb71102cea154dc463f4a020561e2a40e835f9506e2c1bbd37b14bf169fee9e41d94d6481153999c6486b28361ac1db2896543a42d0e8d1feb8c9f038e3226b19daf72d454dfd6d9928beccc3b3132d1f4e996ca49f276000e9f48d9ef6726c487c0fb0ede75c69464278892803a74fa9ae56044cea55931ab6c214fe440522aa3770963ae910e8656eb8abe6d1ba0f0633644071a1cb7dc1111eeceaa16a2c3ddb66a555db4c412337dcb28895cee7cfc1dc8304a16fedf2f7de4fd1160d528087c2f55ea33a2c7feca95ca24dc71e1a6f0d978bf0d36bded077f5d55490ee150f2f83ed8009cbe76ecf7f922b047052e83da4706ffc1e2a4f19b3ce0f1c1ca1279bb60d8b94069cfb3fb5c328d63bd821e47866b6902a3c85a02cfe1be4664418a32eed422709c5a536648805b726b053da8269fa65e5945c3f0897566fff4a9aabb4df74fc48378fcaf9cc69d7cd820d0dd682d41af39663e3a12ceb63a4f5a875d2a1df3aa924270ae2d4640e53f8edaadb3f53a8a460dd5c7d3c5cb54cafba1911314d809091d593083dcf397a2e4b9258ced80169c0d7728b869be03b5882045a1afbb0275b1073bef509f2db72fe133df00fbb27ee2ef6ce6fd0e2608387175de108b1fb40b74746a337531375bee5563b9b2ee6b9d803be7bf52b7013f87bf4b7481641b2ab5479fe5bab4409858506ebd120c8350a53ac86e15c8c12485ab9f58c218c9e2f44a39633abcc333017635b19af3330dd4dc275e9848912363a85e7cb3e91ec588b171e936af4a63768edffd74fa05a2fa9e281cff6eb2d801b2fbb8e208dbabdffd387331b9239f53a80414cc223a6230ad96143e624f210d541de65584ebee32586c31be681dd2527d2a52576744ebeb91735f4ec6cb2f4bc8f94dc0f810fbab5e14304c370ea8fa4c208376712cfedf6dff2c4cea55968d3316d87cc68f0ea97eb59e79a5822b9e6e69020000000100f2052a010000001976a914b9e262e30df03e88ccea312652bc83ca7290c8fc88ac00000000 +01000000015d29bd6aaefc76d42e3f23340324be0d235a35ff6ab80187be75f3c3d9cf8c44010000006b483045022100bdc6b51c114617e29e28390dc9b3ad95b833ca3d1f0429ba667c58a667f9124702204ca2ed362dd9ef723ddbdcf4185b47c28b127a36f46bc4717662be863309b3e601210387e7ff08b953e3736955408fc6ebcd8aa84a04cc4b45758ea29cc2cfe1820535feffffff02002465c7090000001976a91429bef7962c5c65a2f0f4f7d9ec791866c54f851688ac001194fb180000001976a914e2cee7b71c3a4637dbdfe613f19f4b4f2d070d7f88acf8ec0100 +01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1803a1860104dba36e5b082a00077c00000000052f6d70682f000000000740a9e7a6000000001976a91436e086acf6561a68ba64196e7b92b606d0b8516688ac002f6859000000001976a914381a5dd1a279e8e63e67cde39ecfa61a99dd2ba288ac00e1f505000000001976a9147d9ed014fc4e603fca7c2e3f9097fb7d0fb487fc88ac00e1f505000000001976a914bc7e5a5234db3ab82d74c396ad2b2af419b7517488ac00e1f505000000001976a914ff71b0c9c2a90c6164a50a2fb523eb54a8a6b55088ac00a3e111000000001976a9140654dd9b856f2ece1d56cb4ee5043cd9398d962c88ac00e1f505000000001976a9140b4bfb256ef4bfa360e3b9e66e53a0bd84d196bc88ac00000000 +03000600000000000000fd4901010094140000010001eb44148fbbe7c36e1406ea68701f9b9dd5e10f3aeecbbad9885d73846bf08919320000000000000032000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +03000500010000000000000000000000000000000000000000000000000000000000000000ffffffff0502b4140101ffffffff07004e725300000000232103fb09a216761d5e7f248294970c2370f7f84ce1ad564b8e7096b1e19116af1d52ac80f0fa02000000001976a914296134d2415bf1f2b518b3f673816d7e603b160088ac80f0fa02000000001976a914e1e1dc06a889c1b6d3eb00eef7a96f6a7cfb884888ac80f0fa02000000001976a914ab03ecfddee6330497be894d16c29ae341c123aa88ac80d1f008000000001976a9144281a58a1d5b2d3285e00cb45a8492debbdad4c588ac80f0fa02000000001976a9141fd264c0bb53bd9fef18e2248ddf1383d6e811ae88ac8017b42c000000001976a91471a3892d164ffa3829078bf9ad5f114a3908ce5588ac00000000260100b414000037ff812f8ad1814d4acff9c45457873553fa2ef12602c1386ec6894e7fbb9b95 \ No newline at end of file diff --git a/bchain/coins/flo/floparser.go b/bchain/coins/flo/floparser.go index 3ffb5e39d4..46d098bf59 100644 --- a/bchain/coins/flo/floparser.go +++ b/bchain/coins/flo/floparser.go @@ -3,8 +3,8 @@ package flo import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // magic numbers diff --git a/bchain/coins/flo/floparser_test.go b/bchain/coins/flo/floparser_test.go index f23cf48a2a..c5b9698b0b 100644 --- a/bchain/coins/flo/floparser_test.go +++ b/bchain/coins/flo/floparser_test.go @@ -9,7 +9,7 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { diff --git a/bchain/coins/flo/florpc.go b/bchain/coins/flo/florpc.go index 1a4098a558..a875af9aa8 100644 --- a/bchain/coins/flo/florpc.go +++ b/bchain/coins/flo/florpc.go @@ -5,8 +5,8 @@ import ( "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // FloRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/fujicoin/fujicoinparser.go b/bchain/coins/fujicoin/fujicoinparser.go index f9a0a00ccd..7c4a72d90c 100644 --- a/bchain/coins/fujicoin/fujicoinparser.go +++ b/bchain/coins/fujicoin/fujicoinparser.go @@ -3,7 +3,7 @@ package fujicoin import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/btc" ) const ( @@ -43,7 +43,9 @@ type FujicoinParser struct { // NewFujicoinParser returns new FujicoinParser instance func NewFujicoinParser(params *chaincfg.Params, c *btc.Configuration) *FujicoinParser { - return &FujicoinParser{BitcoinParser: btc.NewBitcoinParser(params, c)} + p := &FujicoinParser{BitcoinParser: btc.NewBitcoinParser(params, c)} + p.VSizeSupport = false + return p } // GetChainParams contains network parameters for the main Fujicoin network, diff --git a/bchain/coins/fujicoin/fujicoinparser_test.go b/bchain/coins/fujicoin/fujicoinparser_test.go index 8ba4e7532e..6171f14e00 100644 --- a/bchain/coins/fujicoin/fujicoinparser_test.go +++ b/bchain/coins/fujicoin/fujicoinparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { diff --git a/bchain/coins/fujicoin/fujicoinrpc.go b/bchain/coins/fujicoin/fujicoinrpc.go index d361378462..afbbb80a67 100644 --- a/bchain/coins/fujicoin/fujicoinrpc.go +++ b/bchain/coins/fujicoin/fujicoinrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // FujicoinRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/gamecredits/gamecreditsparser.go b/bchain/coins/gamecredits/gamecreditsparser.go index 022d4e1506..b209e8746e 100644 --- a/bchain/coins/gamecredits/gamecreditsparser.go +++ b/bchain/coins/gamecredits/gamecreditsparser.go @@ -3,7 +3,7 @@ package gamecredits import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/btc" ) // magic numbers diff --git a/bchain/coins/gamecredits/gamecreditsparser_test.go b/bchain/coins/gamecredits/gamecreditsparser_test.go index 9972e6dbcb..7d9f66cb97 100644 --- a/bchain/coins/gamecredits/gamecreditsparser_test.go +++ b/bchain/coins/gamecredits/gamecreditsparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { diff --git a/bchain/coins/gamecredits/gamecreditsrpc.go b/bchain/coins/gamecredits/gamecreditsrpc.go index 4c7e770443..9ce1ed21aa 100644 --- a/bchain/coins/gamecredits/gamecreditsrpc.go +++ b/bchain/coins/gamecredits/gamecreditsrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // GameCreditsRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/grs/grsparser.go b/bchain/coins/grs/grsparser.go index f7eda35527..556fb8d840 100644 --- a/bchain/coins/grs/grsparser.go +++ b/bchain/coins/grs/grsparser.go @@ -4,20 +4,24 @@ import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/base58" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // magic numbers const ( MainnetMagic wire.BitcoinNet = 0xd4b4bef9 TestnetMagic wire.BitcoinNet = 0x0709110b + RegtestMagic wire.BitcoinNet = 0xdab5bffa + SignetMagic wire.BitcoinNet = 0x7696b422 ) // chain parameters var ( MainNetParams chaincfg.Params TestNetParams chaincfg.Params + RegTestParams chaincfg.Params + SigNetParams chaincfg.Params ) func init() { @@ -38,6 +42,24 @@ func init() { TestNetParams.ScriptHashAddrID = []byte{196} TestNetParams.Bech32HRPSegwit = "tgrs" TestNetParams.Base58CksumHasher = base58.Groestl512D + + RegTestParams = chaincfg.RegressionNetParams + RegTestParams.Net = RegtestMagic + + // Address encoding magics + RegTestParams.PubKeyHashAddrID = []byte{111} + RegTestParams.ScriptHashAddrID = []byte{196} + RegTestParams.Bech32HRPSegwit = "grsrt" + RegTestParams.Base58CksumHasher = base58.Groestl512D + + SigNetParams = chaincfg.SigNetParams + SigNetParams.Net = SignetMagic + + // Address encoding magics + SigNetParams.PubKeyHashAddrID = []byte{111} + SigNetParams.ScriptHashAddrID = []byte{196} + SigNetParams.Bech32HRPSegwit = "tgrs" + SigNetParams.Base58CksumHasher = base58.Groestl512D } // GroestlcoinParser handle @@ -70,6 +92,10 @@ func GetChainParams(chain string) *chaincfg.Params { switch chain { case "test": return &TestNetParams + case "regtest": + return &RegTestParams + case "signet": + return &SigNetParams default: return &MainNetParams } @@ -83,4 +109,4 @@ func (p *GroestlcoinParser) PackTx(tx *bchain.Tx, height uint32, blockTime int64 // UnpackTx unpacks transaction from protobuf byte array func (p *GroestlcoinParser) UnpackTx(buf []byte) (*bchain.Tx, uint32, error) { return p.baseparser.UnpackTx(buf) -} \ No newline at end of file +} diff --git a/bchain/coins/grs/grsparser_test.go b/bchain/coins/grs/grsparser_test.go index 40b6d18732..29ac6c5663 100644 --- a/bchain/coins/grs/grsparser_test.go +++ b/bchain/coins/grs/grsparser_test.go @@ -11,15 +11,15 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) var ( testTx1, testTx2 bchain.Tx - testTxPacked1 = "0a20f56521b17b828897f72b30dd21b0192fd942342e89acbb06abf1d446282c30f512bf0101000000014a9d1fdba915e0907ab02f04f88898863112a2b4fdcf872c7414588c47c874cb000000006a47304402201fb96d20d0778f54520ab59afe70d5fb20e500ecc9f02281cf57934e8029e8e10220383d5a3e80f2e1eb92765b6da0f23d454aecbd8236f083d483e9a7430236876101210331693756f749180aeed0a65a0fab0625a2250bd9abca502282a4cf0723152e67ffffffff01a0330300000000001976a914fe40329c95c5598ac60752a5310b320cb52d18e688ac0000000018ffff87da05200028a6f383013298010a001220cb74c8478c5814742c87cffdb4a21231869888f8042fb07a90e015a9db1f9d4a1800226a47304402201fb96d20d0778f54520ab59afe70d5fb20e500ecc9f02281cf57934e8029e8e10220383d5a3e80f2e1eb92765b6da0f23d454aecbd8236f083d483e9a7430236876101210331693756f749180aeed0a65a0fab0625a2250bd9abca502282a4cf0723152e6728ffffffff0f3a460a030333a010001a1976a914fe40329c95c5598ac60752a5310b320cb52d18e688ac222246744d347a416e39615659674867786d616d5742675750795a7362365268766b41394000" - testTxPacked2 = "0a209b5c4859a8a31e69788cb4402812bb28f14ad71cbd8c60b09903478bc56f79a312e00101000000000101d1613f483f2086d076c82fe34674385a86beb08f052d5405fe1aed397f852f4f0000000000feffffff02404b4c000000000017a9147a55d61848e77ca266e79a39bfc85c580a6426c987a8386f0000000000160014cc8067093f6f843d6d3e22004a4290cd0c0f336b02483045022100ea8780bc1e60e14e945a80654a41748bbf1aa7d6f2e40a88d91dfc2de1f34bd10220181a474a3420444bd188501d8d270736e1e9fe379da9970de992ff445b0972e3012103adc58245cf28406af0ef5cc24b8afba7f1be6c72f279b642d85c48798685f862d9ed090018caa384da0520d9db2728dadb27322c0a0012204f2f857f39ed1afe05542d058fb0be865a387446e32fc876d086203f483f61d1180028feffffff0f3a450a034c4b4010001a17a9147a55d61848e77ca266e79a39bfc85c580a6426c9872223324e345135466855323439374272794666556762716b414a453837614b4476335633653a4d0a036f38a810011a160014cc8067093f6f843d6d3e22004a4290cd0c0f336b222c746772733171656a7178777a666c64377a72366d663779677179357335736535787137766d74396c6b6435374000" + testTxPacked1 = "0a20f56521b17b828897f72b30dd21b0192fd942342e89acbb06abf1d446282c30f512bf0101000000014a9d1fdba915e0907ab02f04f88898863112a2b4fdcf872c7414588c47c874cb000000006a47304402201fb96d20d0778f54520ab59afe70d5fb20e500ecc9f02281cf57934e8029e8e10220383d5a3e80f2e1eb92765b6da0f23d454aecbd8236f083d483e9a7430236876101210331693756f749180aeed0a65a0fab0625a2250bd9abca502282a4cf0723152e67ffffffff01a0330300000000001976a914fe40329c95c5598ac60752a5310b320cb52d18e688ac0000000018ffff87da0528a6f383013294011220cb74c8478c5814742c87cffdb4a21231869888f8042fb07a90e015a9db1f9d4a226a47304402201fb96d20d0778f54520ab59afe70d5fb20e500ecc9f02281cf57934e8029e8e10220383d5a3e80f2e1eb92765b6da0f23d454aecbd8236f083d483e9a7430236876101210331693756f749180aeed0a65a0fab0625a2250bd9abca502282a4cf0723152e6728ffffffff0f3a440a030333a01a1976a914fe40329c95c5598ac60752a5310b320cb52d18e688ac222246744d347a416e39615659674867786d616d5742675750795a7362365268766b4139" + testTxPacked2 = "0a209b5c4859a8a31e69788cb4402812bb28f14ad71cbd8c60b09903478bc56f79a312e00101000000000101d1613f483f2086d076c82fe34674385a86beb08f052d5405fe1aed397f852f4f0000000000feffffff02404b4c000000000017a9147a55d61848e77ca266e79a39bfc85c580a6426c987a8386f0000000000160014cc8067093f6f843d6d3e22004a4290cd0c0f336b02483045022100ea8780bc1e60e14e945a80654a41748bbf1aa7d6f2e40a88d91dfc2de1f34bd10220181a474a3420444bd188501d8d270736e1e9fe379da9970de992ff445b0972e3012103adc58245cf28406af0ef5cc24b8afba7f1be6c72f279b642d85c48798685f862d9ed090018caa384da0520d9db2728dadb27322812204f2f857f39ed1afe05542d058fb0be865a387446e32fc876d086203f483f61d128feffffff0f3a430a034c4b401a17a9147a55d61848e77ca266e79a39bfc85c580a6426c9872223324e345135466855323439374272794666556762716b414a453837614b4476335633653a4d0a036f38a810011a160014cc8067093f6f843d6d3e22004a4290cd0c0f336b222c746772733171656a7178777a666c64377a72366d663779677179357335736535787137766d74396c6b643537" ) func init() { diff --git a/bchain/coins/grs/grsrpc.go b/bchain/coins/grs/grsrpc.go index 62f74c7e83..b7f38c6843 100644 --- a/bchain/coins/grs/grsrpc.go +++ b/bchain/coins/grs/grsrpc.go @@ -5,8 +5,8 @@ import ( "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // GroestlcoinRPC is an interface to JSON-RPC service diff --git a/bchain/coins/koto/kotoparser.go b/bchain/coins/koto/kotoparser.go index a780854c74..7f3c1c5f85 100644 --- a/bchain/coins/koto/kotoparser.go +++ b/bchain/coins/koto/kotoparser.go @@ -3,8 +3,8 @@ package koto import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // magic numbers diff --git a/bchain/coins/koto/kotoparser_test.go b/bchain/coins/koto/kotoparser_test.go index d440020c01..780ba1c8c6 100644 --- a/bchain/coins/koto/kotoparser_test.go +++ b/bchain/coins/koto/kotoparser_test.go @@ -11,15 +11,15 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) var ( testTx1, testTx2 bchain.Tx - testTxPacked1 = "0a2097f944e3558cc784f4013b3753ce9570fe4707893eda724b12eb4c69686113a612970f020000000001036b2048020000001976a9142df466d79cf4be0f7d1091512f1c297e4988fdd188ac000000000100000000000000001392204802000000a8a3d9a54a3fe3b5ae208fa2d96faea96dac6cb76b03bb2e32c5dd892a5d6f6490f05d8f6fb4401228df1be9f22c5ad69706c461bbc9253ffbc3531770e5075e149ec6af7f2cda0cb87862620792340bc425095115adbf0f16f4d3ece3f5c467cbcb02b01ced7192ab644f96fa01f04a7a450a42da2bfad1748634f7bc141467d3c94961ed6250dd23fca62b30b4a2ca6fbad0724372a429bcb97a28954e1681c0bb974877dac26eb2b994eaa23d56ecfdedd93f8331f9432f12adc37da9f049585179b7bbb370b76c6c37a438a20bc3b410a6a72ff8d11408a337a37bbc73dd15df8a34f2c878dc6d6db4f0cee504680fe53e0a158f1e1c82b84e065a4764fc57f8bf75d28899126917a05bf3230036f2d6b38f8a51214d1c2d0588a95e82f0032c2dfa6916c689f8daa648c01517bbf0826d2d4082067b0d17071920eb6dab6c0307603b825f3aae347db349628d4fcaa97a155ef1a2c601170fe825609efa964f0a06700afc135542ea7b06fc989424d7e100652c0ad5be4ca01c1fd676530e6f60606c5feb7de5da0d69544d8b7be8de06b27ba96a1bfa6bc07cc0269982acb722032a938ab36089c0eeda7a4076cd258a1752486a3d52af16db8dff072bcc61f17503185b5bc8aa0ea3a181663bb0ddd3cca1a19293764b01569b9878a60c0ce82e21020751a4ecc1a2a9b9a123042ee8d8a5d4d3f4764b1cdb13d57d2a77c3b56bd89102302d118ebc14969ade27bf83f0e707a97b26c7292a6c20e850abce5fad0ab59d032fec0d6278bbe0f2fb3fbc61697ba10b6ec3f2c4196e46e98dfb65bb28ec6afff81de5a4be7be8c4f56ab4c03043a3cf9987b630ac4b6d8aa74ce8ed5b61040262239172c6450f9ab642e2f2d258c200c3bb4ad69011ef2dfe1dd63d758c86270ac4925b248b9bb4b6c9ff7bf2e56260cba02b2429648dc20eb034c8f9b18e1f6a38b8651c236554546585b4dd0f07fd5ee1696bf792527ce84b22012439300797103f22d3969df725e4414d899ec32a2ebbb857cc911e374e84738f4e007ee5260ff666a286e45c465525e2e3fc5e5e0e9ad82e53d364e4fd355619711c616d508470b997af44f62f283871cd892552128135aadb40c6f8cf69ee72acf349e9f4d33e8673450b9f69d4022a8d886b0cdbaba0798e0bf57b42dedfafb0bbd5495ca1c0030bbf460b48f9a138f6ab748df9046d9995f895062583ce0818e40afb9704653e11d58ca42bd3f60f4e908589ad9144c76067dc433cad13a5bbd9c168691b8c6cddb19d812f3e3f98e2cdd20dbb170936fd5cd2ef0bca72af8931a1b01d6081ffbef5be4416e696a7c762a375b368f71dc31362a4005750992a48e55311dde8d2013180d62e507ffc3e468c4a27acc763a9651b19f37e1ffca7e656225617368e79c1d18f9b14d770993d3d1dc42dcfd9adfa02a8ddf0ebc8fbb850fab307fd1d239cf6ad4e5ff40992dd974bc43fa351ce807cc0036c2f7d80bbd052f496216304fbc63e8d728bf129acaedb0073aff077e584ce04bc1ccf9c91f41f3c8804dd65da4ecdfdba32590e04b4d1b6895dea8edacd1f40313e8a1d4900d0dba54056eee72e3d155e9c67e7a51df581c33cbd39f16549d590ae5387fe2c5ad3484ad5c7da320066b79083c49879e45938b3bbb063726008a2ebb8847c9e57be6ec489c7aaa80f5e8e430040cb8d60298363df850cb7b4e98e97192882d10d2fd96cc490dd18b263d96aac6aa4f5583770e0917fa9b566dd0e0b218c6684007ec10cf11747e8f039fac5250170de2835ab88fea356b6a7d0f5e81ffe9b78d191a745e0237a256a2a840880689d83503b72462e3955b61e22afde947c1f1527ea94151c5b7d3a72ce68979603911c08bcec01097899fd30347be7f2e246f70d2af6a1e29b54988978a91f79b2ed8be76ffd62f79de5418933ed166be919d9bbb7524347d87d31afaed05e71a82b09c18c196b3ba6e226939b375903f7d889422863567203814484af89fd223ce1c959b1fdffaf26461630c630d2bbf99228a096ea6cb0d61df70d24414c76bf9371c4abff0ad257098189af6ea32200fbe092d875aa4d3f72a7ec138439e4b08fb1dcde6a90f25fde1498773e693c9b21c40505d42edcbcaed8a2dc4642750e9df73e169f9986ddb3a57991ac2cb3b540d788e2c2c22c2c51b2d74a98ed59a8cec89ba54342fb9660449a116f8691da60cb447afe4d5e80f37b4669e6007c1cadc41933fb27bab41afd312c37e5cb43715cb4013efcd91221ed06249540b733c05e81131aba75ba0f427d9bd975554b2d49a8048f0b3a84477e75290235fd3bbcaee6c4438ba72299dd960f3f6ee9241f7e399684e894d7bb1c302ecbe24d0f19dca982a82ee44f36211d23b0ea623c9c9f4f527f4e452fd06ebb943cadeea3d7fa42cabd25324bc5851e40f9952823f56b50b97729e6561f2100c2b5922860c6cf447a668324ca931f2f35a5edb7d306f8b8802f98cf67140a3fe73099ab86bb65c439a8593e64816bcd46aa4c254918f4a3a0f3f47b4ebcfb2824703f9a7d163824484ce6fe1852c4ba131ee2635de14822a8cb3782697fecc6f69514edd3f42fcf2751075b838bf14ae91e9dcff517bf3cec4db1b986b4c966a4fa40d38f2ebb7fc60218c397a2d705200028d09a0c3a490a050248206b0310001a1976a9142df466d79cf4be0f7d1091512f1c297e4988fdd188ac22236b31323257767a46415565444b3667353238563376726547673870614a5137624248364000" - testTxPacked2 = "0a203aebcf5a223450bca3c0312d3d87b6070447e795d09a266a3a01c70e44c7cc4812e1010100000001cbc2c0b14b26f563ceee8201971b2caae2a4f964d0fd91267290c51a6a171411010000006a473044022032dd5d573c3a7f729da1cb9d9ba02a08e05d50b4f74d5aeb7cb22284526f70340220661ca4a192d02684f0b6b52768b9e9ae5fad41b962aa918537b91bba275e92e70121024e98e62782ba44e5677b52b1e4e973a027c7d873915a6d62ba967b2c07467224ffffffff02c0c62d00000000001976a914dd985697513887236c484acc605ece839e2204ac88ac989e8ce0000000001976a91482bfe75940a6d46238f55e258fcae5bef4e847ea88ac0000000018ff98a2d705200028d49a0c3298010a0012201114176a1ac590722691fdd064f9a4e2aa2c1b970182eece63f5264bb1c0c2cb1801226a473044022032dd5d573c3a7f729da1cb9d9ba02a08e05d50b4f74d5aeb7cb22284526f70340220661ca4a192d02684f0b6b52768b9e9ae5fad41b962aa918537b91bba275e92e70121024e98e62782ba44e5677b52b1e4e973a027c7d873915a6d62ba967b2c0746722428ffffffff0f3a470a032dc6c010001a1976a914dd985697513887236c484acc605ece839e2204ac88ac22236b314a334461347236356653616b6571555953616a6f506f74656376633768384861513a480a04e08c9e9810011a1976a91482bfe75940a6d46238f55e258fcae5bef4e847ea88ac22236b31396b7355666462355139584b556a3565645570314451686e6343503868396845374000" + testTxPacked1 = "0a2097f944e3558cc784f4013b3753ce9570fe4707893eda724b12eb4c69686113a612970f020000000001036b2048020000001976a9142df466d79cf4be0f7d1091512f1c297e4988fdd188ac000000000100000000000000001392204802000000a8a3d9a54a3fe3b5ae208fa2d96faea96dac6cb76b03bb2e32c5dd892a5d6f6490f05d8f6fb4401228df1be9f22c5ad69706c461bbc9253ffbc3531770e5075e149ec6af7f2cda0cb87862620792340bc425095115adbf0f16f4d3ece3f5c467cbcb02b01ced7192ab644f96fa01f04a7a450a42da2bfad1748634f7bc141467d3c94961ed6250dd23fca62b30b4a2ca6fbad0724372a429bcb97a28954e1681c0bb974877dac26eb2b994eaa23d56ecfdedd93f8331f9432f12adc37da9f049585179b7bbb370b76c6c37a438a20bc3b410a6a72ff8d11408a337a37bbc73dd15df8a34f2c878dc6d6db4f0cee504680fe53e0a158f1e1c82b84e065a4764fc57f8bf75d28899126917a05bf3230036f2d6b38f8a51214d1c2d0588a95e82f0032c2dfa6916c689f8daa648c01517bbf0826d2d4082067b0d17071920eb6dab6c0307603b825f3aae347db349628d4fcaa97a155ef1a2c601170fe825609efa964f0a06700afc135542ea7b06fc989424d7e100652c0ad5be4ca01c1fd676530e6f60606c5feb7de5da0d69544d8b7be8de06b27ba96a1bfa6bc07cc0269982acb722032a938ab36089c0eeda7a4076cd258a1752486a3d52af16db8dff072bcc61f17503185b5bc8aa0ea3a181663bb0ddd3cca1a19293764b01569b9878a60c0ce82e21020751a4ecc1a2a9b9a123042ee8d8a5d4d3f4764b1cdb13d57d2a77c3b56bd89102302d118ebc14969ade27bf83f0e707a97b26c7292a6c20e850abce5fad0ab59d032fec0d6278bbe0f2fb3fbc61697ba10b6ec3f2c4196e46e98dfb65bb28ec6afff81de5a4be7be8c4f56ab4c03043a3cf9987b630ac4b6d8aa74ce8ed5b61040262239172c6450f9ab642e2f2d258c200c3bb4ad69011ef2dfe1dd63d758c86270ac4925b248b9bb4b6c9ff7bf2e56260cba02b2429648dc20eb034c8f9b18e1f6a38b8651c236554546585b4dd0f07fd5ee1696bf792527ce84b22012439300797103f22d3969df725e4414d899ec32a2ebbb857cc911e374e84738f4e007ee5260ff666a286e45c465525e2e3fc5e5e0e9ad82e53d364e4fd355619711c616d508470b997af44f62f283871cd892552128135aadb40c6f8cf69ee72acf349e9f4d33e8673450b9f69d4022a8d886b0cdbaba0798e0bf57b42dedfafb0bbd5495ca1c0030bbf460b48f9a138f6ab748df9046d9995f895062583ce0818e40afb9704653e11d58ca42bd3f60f4e908589ad9144c76067dc433cad13a5bbd9c168691b8c6cddb19d812f3e3f98e2cdd20dbb170936fd5cd2ef0bca72af8931a1b01d6081ffbef5be4416e696a7c762a375b368f71dc31362a4005750992a48e55311dde8d2013180d62e507ffc3e468c4a27acc763a9651b19f37e1ffca7e656225617368e79c1d18f9b14d770993d3d1dc42dcfd9adfa02a8ddf0ebc8fbb850fab307fd1d239cf6ad4e5ff40992dd974bc43fa351ce807cc0036c2f7d80bbd052f496216304fbc63e8d728bf129acaedb0073aff077e584ce04bc1ccf9c91f41f3c8804dd65da4ecdfdba32590e04b4d1b6895dea8edacd1f40313e8a1d4900d0dba54056eee72e3d155e9c67e7a51df581c33cbd39f16549d590ae5387fe2c5ad3484ad5c7da320066b79083c49879e45938b3bbb063726008a2ebb8847c9e57be6ec489c7aaa80f5e8e430040cb8d60298363df850cb7b4e98e97192882d10d2fd96cc490dd18b263d96aac6aa4f5583770e0917fa9b566dd0e0b218c6684007ec10cf11747e8f039fac5250170de2835ab88fea356b6a7d0f5e81ffe9b78d191a745e0237a256a2a840880689d83503b72462e3955b61e22afde947c1f1527ea94151c5b7d3a72ce68979603911c08bcec01097899fd30347be7f2e246f70d2af6a1e29b54988978a91f79b2ed8be76ffd62f79de5418933ed166be919d9bbb7524347d87d31afaed05e71a82b09c18c196b3ba6e226939b375903f7d889422863567203814484af89fd223ce1c959b1fdffaf26461630c630d2bbf99228a096ea6cb0d61df70d24414c76bf9371c4abff0ad257098189af6ea32200fbe092d875aa4d3f72a7ec138439e4b08fb1dcde6a90f25fde1498773e693c9b21c40505d42edcbcaed8a2dc4642750e9df73e169f9986ddb3a57991ac2cb3b540d788e2c2c22c2c51b2d74a98ed59a8cec89ba54342fb9660449a116f8691da60cb447afe4d5e80f37b4669e6007c1cadc41933fb27bab41afd312c37e5cb43715cb4013efcd91221ed06249540b733c05e81131aba75ba0f427d9bd975554b2d49a8048f0b3a84477e75290235fd3bbcaee6c4438ba72299dd960f3f6ee9241f7e399684e894d7bb1c302ecbe24d0f19dca982a82ee44f36211d23b0ea623c9c9f4f527f4e452fd06ebb943cadeea3d7fa42cabd25324bc5851e40f9952823f56b50b97729e6561f2100c2b5922860c6cf447a668324ca931f2f35a5edb7d306f8b8802f98cf67140a3fe73099ab86bb65c439a8593e64816bcd46aa4c254918f4a3a0f3f47b4ebcfb2824703f9a7d163824484ce6fe1852c4ba131ee2635de14822a8cb3782697fecc6f69514edd3f42fcf2751075b838bf14ae91e9dcff517bf3cec4db1b986b4c966a4fa40d38f2ebb7fc60218c397a2d70528d09a0c3a470a050248206b031a1976a9142df466d79cf4be0f7d1091512f1c297e4988fdd188ac22236b31323257767a46415565444b3667353238563376726547673870614a513762424836" + testTxPacked2 = "0a203aebcf5a223450bca3c0312d3d87b6070447e795d09a266a3a01c70e44c7cc4812e1010100000001cbc2c0b14b26f563ceee8201971b2caae2a4f964d0fd91267290c51a6a171411010000006a473044022032dd5d573c3a7f729da1cb9d9ba02a08e05d50b4f74d5aeb7cb22284526f70340220661ca4a192d02684f0b6b52768b9e9ae5fad41b962aa918537b91bba275e92e70121024e98e62782ba44e5677b52b1e4e973a027c7d873915a6d62ba967b2c07467224ffffffff02c0c62d00000000001976a914dd985697513887236c484acc605ece839e2204ac88ac989e8ce0000000001976a91482bfe75940a6d46238f55e258fcae5bef4e847ea88ac0000000018ff98a2d70528d49a0c32960112201114176a1ac590722691fdd064f9a4e2aa2c1b970182eece63f5264bb1c0c2cb1801226a473044022032dd5d573c3a7f729da1cb9d9ba02a08e05d50b4f74d5aeb7cb22284526f70340220661ca4a192d02684f0b6b52768b9e9ae5fad41b962aa918537b91bba275e92e70121024e98e62782ba44e5677b52b1e4e973a027c7d873915a6d62ba967b2c0746722428ffffffff0f3a450a032dc6c01a1976a914dd985697513887236c484acc605ece839e2204ac88ac22236b314a334461347236356653616b6571555953616a6f506f74656376633768384861513a480a04e08c9e9810011a1976a91482bfe75940a6d46238f55e258fcae5bef4e847ea88ac22236b31396b7355666462355139584b556a3565645570314451686e634350386839684537" ) func init() { diff --git a/bchain/coins/koto/kotorpc.go b/bchain/coins/koto/kotorpc.go index d2460aa9f5..8271d459c3 100644 --- a/bchain/coins/koto/kotorpc.go +++ b/bchain/coins/koto/kotorpc.go @@ -5,8 +5,8 @@ import ( "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // KotoRPC is an interface to JSON-RPC bitcoind service diff --git a/bchain/coins/liquid/liquidparser.go b/bchain/coins/liquid/liquidparser.go index 8ddcabcf38..fe05712a46 100644 --- a/bchain/coins/liquid/liquidparser.go +++ b/bchain/coins/liquid/liquidparser.go @@ -8,8 +8,8 @@ import ( "github.com/martinboehm/btcd/txscript" "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) const ( diff --git a/bchain/coins/liquid/liquidparser_test.go b/bchain/coins/liquid/liquidparser_test.go index 1b3a337ba3..d76f15a294 100644 --- a/bchain/coins/liquid/liquidparser_test.go +++ b/bchain/coins/liquid/liquidparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -137,7 +137,7 @@ func Test_GetAddressesFromAddrDesc(t *testing.T) { var ( testTx1 bchain.Tx - testTxPacked1 = "0a207aa1af9481f2d744c96015b1baea6ba753790971ff265adfd93775de0234bfd612b51a020000000101a99547d213b005f355da348de54f5eb370fbc6a5687e412897ef0ec4ce237d75020000006b483045022100ae926c96c746308e7488e022f4ad1db94d5d0c8683f6fa6ded3afb13d8e20578022074aa8ebfe20adaf25beed70c60cfb5007278bec17875f11f7a5b3c86eb60a96901210391abdcd113c40b56f13a548e8624d6ad8d7a162b33ef81020d046cadbda26637feffffff030af62b535fc393152f6d575708b271e3a53514cdcf65508c7d12e8fd06709ce24208e192e1315aa94c3bc0eddbb0ae195aff0a1ba2e19773b79e5d1b29fdd8df211b02f8b255b83fa13f40745fb5054b89dc7dcba497c850086a8726bac66ac22d4be31976a914d79c1c8b67a0275c60e33b67bbd0e19a79b9276388ac016d521c38ec1ea15734ae22b7c46064412829c0d0579f0a713d1c04ede979026f01000000000000000000256a23426c6f636b73747265616d2e696e666f206973206120636f6f6c206578706c6f726572016d521c38ec1ea15734ae22b7c46064412829c0d0579f0a713d1c04ede979026f0100000000000004800000000000000000000043010001db986007e38ccb7bdef1661fcf633cbde3d8850cd116736d9b0b0b027901921694954b26036eb51d9424c38083aa3d937998ea81077843925513523eb6ec3337fd4d0b602300000000000000011486003b49c8ab1a6aa09a8eaec4ed83dbda4cb4ef9651114513823a6eefb1dbf971631d88a229a3b027a94f9f15966765f6f5ce3245057c57aedf8f4c69e355f4264a10e2c7dad691879844074ae5e37152c828aba89035e928c63b1c1590638333d05ed08442538ebf7dff9c94ce11ab6bb6d5c9535fce99fab023aabc7215b52eed15454333428b065fb5d8b03aa2fe1de5004f9d8fca717bcd682f1f9caa6561bdafbe69c8423166f7e33867f0bc4fdd85224ff1533839762530d47a1f053c40c7e38f84a1431ad03398bc9d634384aec0f22e75ac4a94e3703ec8715d0791564a8b1509eab4bf7543ef2e5c20fe9fb6c2a85afe42fcdba64c628103ea5693287a28ded79f517a3e877fe287ca6f3d1229295abaf5c1b3b43ab54ec4697c941da47e0934718b697a414d8fd1a722eb23ceb554afb1b4c807a94507a35b153f19da10c6688c71efa0d1ad15c8ac2f3d786ae8c43136cacf333c2643e521322314346b201427f1ac975340cf3caf102245d8cf45709bb51e41fd357f1fb7316138f1992e5646842eb4fe18ef6a7096f7e4c1e69950285f30c64dc1da661c055944356f140d6298f5eaf733799bef034a8afb05f6f74239e572acf5d1c2f9e028b806caab13eb19148456b3db0f719ad76818eaea11e7989e74d858743ffba738a5f5c7d12ab1b049c594656821620e20817c6acc5b3d3725f9183d3c01a0190d5d991d1603682418f4a9c55b59cab8787f463123e1efd7d3e7f75dfb9ccf931ae2dc965b82cabf2af6b93293a4b00d7145d97a24679076157bade5ba4d7577922052c719ebf2493f5e9d0cf8e6f82666e8732dd91068395e528d0ea532e27ae8a84ed516dabd30dd990a1539d93f7b775039d53f1a12d7e7747a64f6aca9d7589330c8adf8854dce0fee0be212716d6c1b075fabb9d0c815e613c772f7c57e7efdb4e64a5ef47c9cf384deab55191d7b4b0cb27986649eca2c4286a01a7d3c304f008c6d3fe53abf320afc7602654cf4b69b87fd2b9b6794fe7cc77e92bbef572dc8ddb1530da6d82268cd32db112afb8c2db69651959c1ad39d804178cae05f856539559e3dd0e869d9985f41262c30058d1d3c2cd336649b3a893bd4b29c206c53eeb337025a7585fc4bc05d8fe71035ea082ea543cbc15f8e6f0658815c7553795917958b503779cf6a18f92c391769e846327d3cd8458c089cb1e342a590eb57a583365c4bd5de34dc17992f5309fb69b67aa8436c763183a8f11a543fdb8a376a30836013fff3a2d21b72e22f1d9d031a3cd467365256e120894e1489c238df5ce65183b93d68024697245be8c0a9d60e31452d247a98136ca559622e2f4b35569ec8ab8539528a09214f1d3862bc97b21b7bb2b66c654ebee00eb26ec57988ff174cab65d4864eea14f15b65252fe534750bea3b9b4400c811ccf3e6517ecf1f1585d908f21b91bfd223eddbb9b980bb65133934c8027fde78865ed4d969bc6de3be613730d3a0f179f0ed734c67900da53c230045d60d31980401e7fa1396891d42555af8152bb0e6557d3c7d718f7ba85b6848ab052fcf709c7aea676d8c00c787a7fb2a2b1972d245aa51a24ceb13d7dd38cd0c214727a1000561828a62c969afa60d36cfab21b8e6837a0e0ac325c6c20e3ccd09fd1f5400dd6a7dc861e050bcb47ee89670d807b514e5b59ea830ef885ac6a0371efdcbb09e676fd9790e00ab9686f59b2ce173896d4d451e6fd61f3b95b82e63a494d7853c78c2f03e45cbabd46a4586364c131000d9efdc71d30d3caf25cdfad63a6628e67d5f219a0cafc5509f556cec7ac110ce0b52b552be6780e8d0c31a068dc6607ebc9cac4471cb1e85e5b6a0bc2330062f6930e2ef623da059da02902fa28586614a053625ea5901dc6151e7fae3a63b444b9d53630dea6b90d3c2ca7dd8db69f39a2ad6bc2eec08a85c1c0d0a9b079825278cf0b6f415344b0b6e4c7a51947e98ada9149c8f427914d8245b152f8f3558178d16e35498649a34ceb2fdaafc0d303829ddd9412c9a2b5ed1ce060472a9a85ffa19a4ddfbdb89437e72b261472d2b8c70a01a96562b753692c7329d75057a9918dbb3a39a90c86b66ec745a14e9909b2eae6c46dcd8c8a666973ba356124d6444353f1a34f88f907af897ea8e642f8603aed400d3d8a75568baa96b26b8d04f5fb0d1ca1256e7057f93464d95d1994ac5189ca607c013637fa35879a6c67c4806b6c9f00ccedb953103bbec21f054d304b621e0eca771accf5181409642d58ea8d032e06c27295e57092b4e3a89f47f47f16dc82f07dfad44ca7077385f62b4ad2efecb97955759c31977719316545b6fc6a78b7e35206719fcba4c1dca5d0f7f9959f7bd1c117532a2f7fc3ca87820e38cfab558dc48adb1058964b6ea9dfde5e06bff6b5d7af19fe6b8a46d0ec76f8902e525082591b82c2f86a30341787a1da86ab517063b39ae076b3f10e78f6c79dd58041b99ab40824b598f6c815733d62d262abeb96b8224b56e4109d4d5c445cd055a9cd2aea7753fc0f6a55bcf4291551a0d9c852e19e0f0603eefc9a79a38bc07a73f6e0f88a9d3eac01ca57d5d55e2ef870743522c15841b4ea7a0d278922cc977724571c75b5039a20f5dad843941d91fb421944b54923ed8c5f71edafbdfaf4c12dbe1c2b2a9642bbc2bd07017a44cb402d6ae09248eb24b70d43f751d3bf761ecef51f1f15239de25222852f95607335b3410050e5bd65f8c9657f0a95185e48dcb2f711641c0994e352b0039d569ddbd27182c263649d3e27b3816acd5822b31c98661adf01ee62db361be1b0469de4fc1c6e10185203eb44abe3d077fcbc52bef6095ca30c81a22b6e7a64c1385e7dd11209fc073a29ef656769fbb24d4292d98379884a1a2b2c7f6fd895577d739a2f0411e8afcee4de40927443f28e72aebc763fd0315ee85d29a23bb235ce82a5a621c0d9021965673720be9057a26b1ff0380263b54777f6cc7b7531d284f5041d4480108e4b4986f1ddec9e65dd2392c7fa0349c2a39954e4aeec8fe8f40e66db57d29beaf0046d9387482bab71a9397d90611cf767637c8666da87d5f1d798558cbb7228844010510cb95b073cec3878893ba70549eb6d3428b8db6944118f6de2ee7107b593ef85441cba46238f7843c4e6e7497f14cc64c25653c87da756226ce774c2b5e43294f2ebab2f601e9fe3f2d1d3172cfcc7e6eb7ea9b237e5f093443b02f42b4ea85673a6a0000ef9a6ddf2263a1a75eb7b78d3f90b1de91a22d78aa06d9626b0f9090ee63d92418b084db647132e3b0b7f3ed583eec280d06b94a358daffcd333233fb390ba8bf2da3921b18adf6cd4901cbabf4f4e3c90f21eb3190c8c0e4f16ac25dd546859bc640354ab9769553aeda466191ba4b10f52da5342347685e52af5d20ba8c113e65663bcedc12c99e576c4e1bdde013017d16fc26e3f30418c21d72ad6507ef1c8d437342d0fc20ad102e6c49eb9a8e7a3df5366c9a75b6d95ab007a3d93bca0086414ac5bc44a872659f43f0f703b415ac0e9aeeedb2c0cb945938923dc0865c5d3ff673e068d2865b11c68774cd6c0be1caa40627425bdc4ecbbd0a642f9c6953464e2f20681994be8483d64ed6d2d8efa79c5b12776900e58b45bea18c2e0220ea27e9670485e2c6ce9f52ac08cad61ca57b839710209db8a5d4fe95a846960b126271e519545e5d4d15300fc0ef2f35b8def7ba6f639d85404b57bac35140bef1c4f3b455773bf2a2eae62118dcdc5474ec0900aca49300833417786d1fb0d76a56570cfa10d23dbab0305e1c9c48032b19fd2ec2e00b1528a248036590e26e8c75209d004e20bc7730b29cf3ed860848b83ab91ad6a635f2cc1eca89e16814f34f2c1c2766a28b2901170bb4839f08f5685e593b8a5ce2a801194818b4aeb0a01794f92c7bc4144808e997cfd1711485b60483cf4a310b23e0210c5c73e6956b9e1ae696f1ea79b3fab788bf229a349fd9caedebd5db99821b1cebdceafb011cfcd1c78f93774b35f25c7e69952b0bca1a0d40791b812614e996ca31548bc0000000018cfdaa0e0052000288781053299010a001220757d23cec40eef9728417e68a5c6fb70b35e4fe58d34da55f305b013d24795a91802226b483045022100ae926c96c746308e7488e022f4ad1db94d5d0c8683f6fa6ded3afb13d8e20578022074aa8ebfe20adaf25beed70c60cfb5007278bec17875f11f7a5b3c86eb60a96901210391abdcd113c40b56f13a548e8624d6ad8d7a162b33ef81020d046cadbda2663728feffffff0f3a4110001a1976a914d79c1c8b67a0275c60e33b67bbd0e19a79b9276388ac222251477652524658424d32666558755533394577484e75595953726e4d33486155794d3a2910011a256a23426c6f636b73747265616d2e696e666f206973206120636f6f6c206578706c6f7265723a060a02048010024002" + testTxPacked1 = "0a207aa1af9481f2d744c96015b1baea6ba753790971ff265adfd93775de0234bfd612b51a020000000101a99547d213b005f355da348de54f5eb370fbc6a5687e412897ef0ec4ce237d75020000006b483045022100ae926c96c746308e7488e022f4ad1db94d5d0c8683f6fa6ded3afb13d8e20578022074aa8ebfe20adaf25beed70c60cfb5007278bec17875f11f7a5b3c86eb60a96901210391abdcd113c40b56f13a548e8624d6ad8d7a162b33ef81020d046cadbda26637feffffff030af62b535fc393152f6d575708b271e3a53514cdcf65508c7d12e8fd06709ce24208e192e1315aa94c3bc0eddbb0ae195aff0a1ba2e19773b79e5d1b29fdd8df211b02f8b255b83fa13f40745fb5054b89dc7dcba497c850086a8726bac66ac22d4be31976a914d79c1c8b67a0275c60e33b67bbd0e19a79b9276388ac016d521c38ec1ea15734ae22b7c46064412829c0d0579f0a713d1c04ede979026f01000000000000000000256a23426c6f636b73747265616d2e696e666f206973206120636f6f6c206578706c6f726572016d521c38ec1ea15734ae22b7c46064412829c0d0579f0a713d1c04ede979026f0100000000000004800000000000000000000043010001db986007e38ccb7bdef1661fcf633cbde3d8850cd116736d9b0b0b027901921694954b26036eb51d9424c38083aa3d937998ea81077843925513523eb6ec3337fd4d0b602300000000000000011486003b49c8ab1a6aa09a8eaec4ed83dbda4cb4ef9651114513823a6eefb1dbf971631d88a229a3b027a94f9f15966765f6f5ce3245057c57aedf8f4c69e355f4264a10e2c7dad691879844074ae5e37152c828aba89035e928c63b1c1590638333d05ed08442538ebf7dff9c94ce11ab6bb6d5c9535fce99fab023aabc7215b52eed15454333428b065fb5d8b03aa2fe1de5004f9d8fca717bcd682f1f9caa6561bdafbe69c8423166f7e33867f0bc4fdd85224ff1533839762530d47a1f053c40c7e38f84a1431ad03398bc9d634384aec0f22e75ac4a94e3703ec8715d0791564a8b1509eab4bf7543ef2e5c20fe9fb6c2a85afe42fcdba64c628103ea5693287a28ded79f517a3e877fe287ca6f3d1229295abaf5c1b3b43ab54ec4697c941da47e0934718b697a414d8fd1a722eb23ceb554afb1b4c807a94507a35b153f19da10c6688c71efa0d1ad15c8ac2f3d786ae8c43136cacf333c2643e521322314346b201427f1ac975340cf3caf102245d8cf45709bb51e41fd357f1fb7316138f1992e5646842eb4fe18ef6a7096f7e4c1e69950285f30c64dc1da661c055944356f140d6298f5eaf733799bef034a8afb05f6f74239e572acf5d1c2f9e028b806caab13eb19148456b3db0f719ad76818eaea11e7989e74d858743ffba738a5f5c7d12ab1b049c594656821620e20817c6acc5b3d3725f9183d3c01a0190d5d991d1603682418f4a9c55b59cab8787f463123e1efd7d3e7f75dfb9ccf931ae2dc965b82cabf2af6b93293a4b00d7145d97a24679076157bade5ba4d7577922052c719ebf2493f5e9d0cf8e6f82666e8732dd91068395e528d0ea532e27ae8a84ed516dabd30dd990a1539d93f7b775039d53f1a12d7e7747a64f6aca9d7589330c8adf8854dce0fee0be212716d6c1b075fabb9d0c815e613c772f7c57e7efdb4e64a5ef47c9cf384deab55191d7b4b0cb27986649eca2c4286a01a7d3c304f008c6d3fe53abf320afc7602654cf4b69b87fd2b9b6794fe7cc77e92bbef572dc8ddb1530da6d82268cd32db112afb8c2db69651959c1ad39d804178cae05f856539559e3dd0e869d9985f41262c30058d1d3c2cd336649b3a893bd4b29c206c53eeb337025a7585fc4bc05d8fe71035ea082ea543cbc15f8e6f0658815c7553795917958b503779cf6a18f92c391769e846327d3cd8458c089cb1e342a590eb57a583365c4bd5de34dc17992f5309fb69b67aa8436c763183a8f11a543fdb8a376a30836013fff3a2d21b72e22f1d9d031a3cd467365256e120894e1489c238df5ce65183b93d68024697245be8c0a9d60e31452d247a98136ca559622e2f4b35569ec8ab8539528a09214f1d3862bc97b21b7bb2b66c654ebee00eb26ec57988ff174cab65d4864eea14f15b65252fe534750bea3b9b4400c811ccf3e6517ecf1f1585d908f21b91bfd223eddbb9b980bb65133934c8027fde78865ed4d969bc6de3be613730d3a0f179f0ed734c67900da53c230045d60d31980401e7fa1396891d42555af8152bb0e6557d3c7d718f7ba85b6848ab052fcf709c7aea676d8c00c787a7fb2a2b1972d245aa51a24ceb13d7dd38cd0c214727a1000561828a62c969afa60d36cfab21b8e6837a0e0ac325c6c20e3ccd09fd1f5400dd6a7dc861e050bcb47ee89670d807b514e5b59ea830ef885ac6a0371efdcbb09e676fd9790e00ab9686f59b2ce173896d4d451e6fd61f3b95b82e63a494d7853c78c2f03e45cbabd46a4586364c131000d9efdc71d30d3caf25cdfad63a6628e67d5f219a0cafc5509f556cec7ac110ce0b52b552be6780e8d0c31a068dc6607ebc9cac4471cb1e85e5b6a0bc2330062f6930e2ef623da059da02902fa28586614a053625ea5901dc6151e7fae3a63b444b9d53630dea6b90d3c2ca7dd8db69f39a2ad6bc2eec08a85c1c0d0a9b079825278cf0b6f415344b0b6e4c7a51947e98ada9149c8f427914d8245b152f8f3558178d16e35498649a34ceb2fdaafc0d303829ddd9412c9a2b5ed1ce060472a9a85ffa19a4ddfbdb89437e72b261472d2b8c70a01a96562b753692c7329d75057a9918dbb3a39a90c86b66ec745a14e9909b2eae6c46dcd8c8a666973ba356124d6444353f1a34f88f907af897ea8e642f8603aed400d3d8a75568baa96b26b8d04f5fb0d1ca1256e7057f93464d95d1994ac5189ca607c013637fa35879a6c67c4806b6c9f00ccedb953103bbec21f054d304b621e0eca771accf5181409642d58ea8d032e06c27295e57092b4e3a89f47f47f16dc82f07dfad44ca7077385f62b4ad2efecb97955759c31977719316545b6fc6a78b7e35206719fcba4c1dca5d0f7f9959f7bd1c117532a2f7fc3ca87820e38cfab558dc48adb1058964b6ea9dfde5e06bff6b5d7af19fe6b8a46d0ec76f8902e525082591b82c2f86a30341787a1da86ab517063b39ae076b3f10e78f6c79dd58041b99ab40824b598f6c815733d62d262abeb96b8224b56e4109d4d5c445cd055a9cd2aea7753fc0f6a55bcf4291551a0d9c852e19e0f0603eefc9a79a38bc07a73f6e0f88a9d3eac01ca57d5d55e2ef870743522c15841b4ea7a0d278922cc977724571c75b5039a20f5dad843941d91fb421944b54923ed8c5f71edafbdfaf4c12dbe1c2b2a9642bbc2bd07017a44cb402d6ae09248eb24b70d43f751d3bf761ecef51f1f15239de25222852f95607335b3410050e5bd65f8c9657f0a95185e48dcb2f711641c0994e352b0039d569ddbd27182c263649d3e27b3816acd5822b31c98661adf01ee62db361be1b0469de4fc1c6e10185203eb44abe3d077fcbc52bef6095ca30c81a22b6e7a64c1385e7dd11209fc073a29ef656769fbb24d4292d98379884a1a2b2c7f6fd895577d739a2f0411e8afcee4de40927443f28e72aebc763fd0315ee85d29a23bb235ce82a5a621c0d9021965673720be9057a26b1ff0380263b54777f6cc7b7531d284f5041d4480108e4b4986f1ddec9e65dd2392c7fa0349c2a39954e4aeec8fe8f40e66db57d29beaf0046d9387482bab71a9397d90611cf767637c8666da87d5f1d798558cbb7228844010510cb95b073cec3878893ba70549eb6d3428b8db6944118f6de2ee7107b593ef85441cba46238f7843c4e6e7497f14cc64c25653c87da756226ce774c2b5e43294f2ebab2f601e9fe3f2d1d3172cfcc7e6eb7ea9b237e5f093443b02f42b4ea85673a6a0000ef9a6ddf2263a1a75eb7b78d3f90b1de91a22d78aa06d9626b0f9090ee63d92418b084db647132e3b0b7f3ed583eec280d06b94a358daffcd333233fb390ba8bf2da3921b18adf6cd4901cbabf4f4e3c90f21eb3190c8c0e4f16ac25dd546859bc640354ab9769553aeda466191ba4b10f52da5342347685e52af5d20ba8c113e65663bcedc12c99e576c4e1bdde013017d16fc26e3f30418c21d72ad6507ef1c8d437342d0fc20ad102e6c49eb9a8e7a3df5366c9a75b6d95ab007a3d93bca0086414ac5bc44a872659f43f0f703b415ac0e9aeeedb2c0cb945938923dc0865c5d3ff673e068d2865b11c68774cd6c0be1caa40627425bdc4ecbbd0a642f9c6953464e2f20681994be8483d64ed6d2d8efa79c5b12776900e58b45bea18c2e0220ea27e9670485e2c6ce9f52ac08cad61ca57b839710209db8a5d4fe95a846960b126271e519545e5d4d15300fc0ef2f35b8def7ba6f639d85404b57bac35140bef1c4f3b455773bf2a2eae62118dcdc5474ec0900aca49300833417786d1fb0d76a56570cfa10d23dbab0305e1c9c48032b19fd2ec2e00b1528a248036590e26e8c75209d004e20bc7730b29cf3ed860848b83ab91ad6a635f2cc1eca89e16814f34f2c1c2766a28b2901170bb4839f08f5685e593b8a5ce2a801194818b4aeb0a01794f92c7bc4144808e997cfd1711485b60483cf4a310b23e0210c5c73e6956b9e1ae696f1ea79b3fab788bf229a349fd9caedebd5db99821b1cebdceafb011cfcd1c78f93774b35f25c7e69952b0bca1a0d40791b812614e996ca31548bc0000000018cfdaa0e005288781053297011220757d23cec40eef9728417e68a5c6fb70b35e4fe58d34da55f305b013d24795a91802226b483045022100ae926c96c746308e7488e022f4ad1db94d5d0c8683f6fa6ded3afb13d8e20578022074aa8ebfe20adaf25beed70c60cfb5007278bec17875f11f7a5b3c86eb60a96901210391abdcd113c40b56f13a548e8624d6ad8d7a162b33ef81020d046cadbda2663728feffffff0f3a3f1a1976a914d79c1c8b67a0275c60e33b67bbd0e19a79b9276388ac222251477652524658424d32666558755533394577484e75595953726e4d33486155794d3a2910011a256a23426c6f636b73747265616d2e696e666f206973206120636f6f6c206578706c6f7265723a060a02048010024002" ) func init() { diff --git a/bchain/coins/liquid/liquidrpc.go b/bchain/coins/liquid/liquidrpc.go index e5e414351f..9c080ffab1 100644 --- a/bchain/coins/liquid/liquidrpc.go +++ b/bchain/coins/liquid/liquidrpc.go @@ -5,8 +5,8 @@ import ( "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // LiquidRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/litecoin/litecoinparser.go b/bchain/coins/litecoin/litecoinparser.go index 9798d71c4e..e95ca8d443 100644 --- a/bchain/coins/litecoin/litecoinparser.go +++ b/bchain/coins/litecoin/litecoinparser.go @@ -1,9 +1,13 @@ package litecoin import ( + "encoding/json" + + "github.com/golang/glog" "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // magic numbers @@ -36,11 +40,17 @@ func init() { // LitecoinParser handle type LitecoinParser struct { *btc.BitcoinLikeParser + baseparser *bchain.BaseParser } // NewLitecoinParser returns new LitecoinParser instance func NewLitecoinParser(params *chaincfg.Params, c *btc.Configuration) *LitecoinParser { - return &LitecoinParser{BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c)} + p := &LitecoinParser{ + BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c), + baseparser: &bchain.BaseParser{}, + } + p.VSizeSupport = true + return p } // GetChainParams contains network parameters for the main Litecoin network, @@ -68,3 +78,66 @@ func GetChainParams(chain string) *chaincfg.Params { return &MainNetParams } } + +// fallbackTx is used to handle situation when Litecoin mainnet returns +// for certain transactions Version 4294967295 instead of -1, which causes json unmarshal error +type fallbackTx struct { + Hex string `json:"hex"` + Txid string `json:"txid"` + Version uint32 `json:"version"` + LockTime uint32 `json:"locktime"` + Vin []bchain.Vin `json:"vin"` + Vout []bchain.Vout `json:"vout"` + BlockHeight uint32 `json:"blockHeight,omitempty"` + // BlockHash string `json:"blockhash,omitempty"` + Confirmations uint32 `json:"confirmations,omitempty"` + Time int64 `json:"time,omitempty"` + Blocktime int64 `json:"blocktime,omitempty"` +} + +// ParseTxFromJson parses JSON message containing transaction and returns Tx struct +func (p *LitecoinParser) ParseTxFromJson(msg json.RawMessage) (*bchain.Tx, error) { + var tx bchain.Tx + err := json.Unmarshal(msg, &tx) + if err != nil { + var fTx fallbackTx + fErr := json.Unmarshal(msg, &fTx) + // log warning with Txid possibly parsed using fallbackTx + glog.Warningf("ParseTxFromJson txid %s to bchain.Tx error %v, using fallback method", fTx.Txid, err) + if fErr != nil { + return nil, fErr + } + tx.Hex = fTx.Hex + tx.Txid = fTx.Txid + tx.Version = int32(fTx.Version) + tx.LockTime = fTx.LockTime + tx.Vin = fTx.Vin + tx.Vout = fTx.Vout + tx.BlockHeight = fTx.BlockHeight + tx.Confirmations = fTx.Confirmations + tx.Time = fTx.Time + tx.Blocktime = fTx.Blocktime + } + + for i := range tx.Vout { + vout := &tx.Vout[i] + // convert vout.JsonValue to big.Int and clear it, it is only temporary value used for unmarshal + vout.ValueSat, err = p.AmountToBigInt(vout.JsonValue) + if err != nil { + return nil, err + } + vout.JsonValue = "" + } + + return &tx, nil +} + +// PackTx packs transaction to byte array using protobuf +func (p *LitecoinParser) PackTx(tx *bchain.Tx, height uint32, blockTime int64) ([]byte, error) { + return p.baseparser.PackTx(tx, height, blockTime) +} + +// UnpackTx unpacks transaction from protobuf byte array +func (p *LitecoinParser) UnpackTx(buf []byte) (*bchain.Tx, uint32, error) { + return p.baseparser.UnpackTx(buf) +} diff --git a/bchain/coins/litecoin/litecoinparser_test.go b/bchain/coins/litecoin/litecoinparser_test.go index b1db43094c..9db208692a 100644 --- a/bchain/coins/litecoin/litecoinparser_test.go +++ b/bchain/coins/litecoin/litecoinparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -224,16 +224,18 @@ func TestGetAddressesFromAddrDesc_Mainnet(t *testing.T) { var ( testTx1 bchain.Tx - testTxPacked1 = "0001e4538ba8d7aa2002000000031e1977dc524bec5929e95d8d0946812944b7b5bda12f5b99fdf557773f2ee65e0100000000ffffffff8a398e44546dce0245452b90130e86832b21fd68f26662bc33aeb7c6c115d23c1900000000ffffffffb807ab93a7fcdff7af6d24581a4a18aa7c1db1ebecba2617a6805b009513940f0c00000000ffffffff020001a04a000000001976a9141ae882e788091732da6910595314447c9e38bd8d88ac27440f00000000001976a9146b474cbf0f6004329b630bdd4798f2c23d1751b688ac00000000" + testTxPacked1 = "0a201c50c1770374d7de2f81a87463a5225bb620d25fd467536223a5b715a47c9e3212c90102000000031e1977dc524bec5929e95d8d0946812944b7b5bda12f5b99fdf557773f2ee65e0100000000ffffffff8a398e44546dce0245452b90130e86832b21fd68f26662bc33aeb7c6c115d23c1900000000ffffffffb807ab93a7fcdff7af6d24581a4a18aa7c1db1ebecba2617a6805b009513940f0c00000000ffffffff020001a04a000000001976a9141ae882e788091732da6910595314447c9e38bd8d88ac27440f00000000001976a9146b474cbf0f6004329b630bdd4798f2c23d1751b688ac000000001890d5abd40528d3c807322a12205ee62e3f7757f5fd995b2fa1bdb5b744298146098d5de92959ec4b52dc77191e180128ffffffff0f322a12203cd215c1c6b7ae33bc6266f268fd212b83860e13902b454502ce6d54448e398a181928ffffffff0f322a12200f941395005b80a61726baecebb11d7caa184a1a58246daff7dffca793ab07b8180c28ffffffff0f3a450a044aa001001a1976a9141ae882e788091732da6910595314447c9e38bd8d88ac22224c4d67454e4e587a7a755078703776664d6a44724355343462736d72454d677176633a460a030f442710011a1976a9146b474cbf0f6004329b630bdd4798f2c23d1751b688ac22224c563142796a624a4e46544879465171777177644a584b4a7a6e59447a587a6734424002489101" ) func init() { testTx1 = bchain.Tx{ Hex: "02000000031e1977dc524bec5929e95d8d0946812944b7b5bda12f5b99fdf557773f2ee65e0100000000ffffffff8a398e44546dce0245452b90130e86832b21fd68f26662bc33aeb7c6c115d23c1900000000ffffffffb807ab93a7fcdff7af6d24581a4a18aa7c1db1ebecba2617a6805b009513940f0c00000000ffffffff020001a04a000000001976a9141ae882e788091732da6910595314447c9e38bd8d88ac27440f00000000001976a9146b474cbf0f6004329b630bdd4798f2c23d1751b688ac00000000", Blocktime: 1519053456, + Time: 1519053456, Txid: "1c50c1770374d7de2f81a87463a5225bb620d25fd467536223a5b715a47c9e32", LockTime: 0, Version: 2, + VSize: 145, Vin: []bchain.Vin{ { ScriptSig: bchain.ScriptSig{ diff --git a/bchain/coins/litecoin/litecoinrpc.go b/bchain/coins/litecoin/litecoinrpc.go index 589f87ca47..ca62445bd6 100644 --- a/bchain/coins/litecoin/litecoinrpc.go +++ b/bchain/coins/litecoin/litecoinrpc.go @@ -4,8 +4,9 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // LitecoinRPC is an interface to JSON-RPC bitcoind service. @@ -56,3 +57,56 @@ func (b *LitecoinRPC) Initialize() error { return nil } + +// GetBlock returns block with given hash. +// Litecoin cannot use optimized BitcoinRPC.GetBlock since v 0.21.2, +// which introduced MWEB fields to the transaction data and made the serialized block incompatible with Bitcoin wire protocol +func (b *LitecoinRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) { + var err error + if hash == "" && height > 0 { + hash, err = b.GetBlockHash(height) + if err != nil { + return nil, err + } + } + + glog.V(1).Info("rpc: getblock (verbosity=1) ", hash) + + res := btc.ResGetBlockThin{} + req := btc.CmdGetBlock{Method: "getblock"} + req.Params.BlockHash = hash + req.Params.Verbosity = 1 + err = b.Call(&req, &res) + + if err != nil { + return nil, errors.Annotatef(err, "hash %v", hash) + } + if res.Error != nil { + return nil, errors.Annotatef(res.Error, "hash %v", hash) + } + + txs := make([]bchain.Tx, 0, len(res.Result.Txids)) + for _, txid := range res.Result.Txids { + tx, err := b.GetTransaction(txid) + if err != nil { + if err == bchain.ErrTxNotFound { + glog.Errorf("rpc: getblock: skipping transaction in block %s due error: %s", hash, err) + continue + } + return nil, err + } + txs = append(txs, *tx) + } + block := &bchain.Block{ + BlockHeader: res.Result.BlockHeader, + Txs: txs, + } + return block, nil +} + +// GetTransactionForMempool returns a transaction by the transaction ID +// Litecoin cannot use optimized BitcoinRPC.GetTransactionForMempool since v 0.21.2, +// which introduced MWEB fields to the transaction data and made the serialized transaction incompatible with Bitcoin wire protocol +func (b *LitecoinRPC) GetTransactionForMempool(txid string) (*bchain.Tx, error) { + return b.GetTransaction(txid) +} diff --git a/bchain/coins/monacoin/monacoinparser.go b/bchain/coins/monacoin/monacoinparser.go index 120ffeaf73..ee6e7e77f1 100644 --- a/bchain/coins/monacoin/monacoinparser.go +++ b/bchain/coins/monacoin/monacoinparser.go @@ -3,7 +3,7 @@ package monacoin import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/btc" ) // magic numbers diff --git a/bchain/coins/monacoin/monacoinparser_test.go b/bchain/coins/monacoin/monacoinparser_test.go index 5944976bfe..14ec7d6ecc 100644 --- a/bchain/coins/monacoin/monacoinparser_test.go +++ b/bchain/coins/monacoin/monacoinparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { diff --git a/bchain/coins/monacoin/monacoinrpc.go b/bchain/coins/monacoin/monacoinrpc.go index cc95650122..120e0c9204 100644 --- a/bchain/coins/monacoin/monacoinrpc.go +++ b/bchain/coins/monacoin/monacoinrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // MonacoinRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/monetaryunit/monetaryunitparser.go b/bchain/coins/monetaryunit/monetaryunitparser.go index 2ec995c2fe..d83dafb3bd 100644 --- a/bchain/coins/monetaryunit/monetaryunitparser.go +++ b/bchain/coins/monetaryunit/monetaryunitparser.go @@ -9,9 +9,9 @@ import ( "github.com/juju/errors" "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) const ( @@ -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/monetaryunit/monetaryunitparser_test.go b/bchain/coins/monetaryunit/monetaryunitparser_test.go index b9b04cb854..c4dc8b7e55 100644 --- a/bchain/coins/monetaryunit/monetaryunitparser_test.go +++ b/bchain/coins/monetaryunit/monetaryunitparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -150,7 +150,7 @@ func Test_GetAddressesFromAddrDesc(t *testing.T) { var ( testTx1 bchain.Tx - testTxPacked1 = "0a20f05ba72a05c4900ff2a00a0403697750201e41267aeea8a589a7dc7bcc57076e12d30101000000010c396f3768565c707addf85ecf47e04cefb2721d95afd977e13f25904de8336a0100000049483045022100fe1b79f38ca4b9dc2fbc50eac6c9bf050ae5a3ee37da05b950918230eb0a8c7c0220477173b60ec00a8b4b28d5db9fc5d8259eea35f91bbdd60089c5ec1be7b05f3b01ffffffff03000000000000000000dd400def140000002321025d145b77df04c40ceb88ea36828755f8275dc5fecf19d3ecccce2d8198c3407cac00d2496b000000001976a914afe70b2e1bf4199298ed8281767bae22970b415088ac0000000018e6b092e605200028f48d1b32770a0012206a33e84d90253fe177d9af951d72b2ef4ce047cf5ef8dd7a705c5668376f390c18012249483045022100fe1b79f38ca4b9dc2fbc50eac6c9bf050ae5a3ee37da05b950918230eb0a8c7c0220477173b60ec00a8b4b28d5db9fc5d8259eea35f91bbdd60089c5ec1be7b05f3b0128ffffffff0f3a04100022003a520a0514ef0d40dd10011a2321025d145b77df04c40ceb88ea36828755f8275dc5fecf19d3ecccce2d8198c3407cac22223764396a4e79716835694b555a566e516a6d7a6d4541513878444b777a4536536e673a470a046b49d20010021a1976a914afe70b2e1bf4199298ed8281767bae22970b415088ac22223769536a6e57436f41556d4a347656584d6b61457561736e5658537a5a7166504c324001" + testTxPacked1 = "0a20f05ba72a05c4900ff2a00a0403697750201e41267aeea8a589a7dc7bcc57076e12d30101000000010c396f3768565c707addf85ecf47e04cefb2721d95afd977e13f25904de8336a0100000049483045022100fe1b79f38ca4b9dc2fbc50eac6c9bf050ae5a3ee37da05b950918230eb0a8c7c0220477173b60ec00a8b4b28d5db9fc5d8259eea35f91bbdd60089c5ec1be7b05f3b01ffffffff03000000000000000000dd400def140000002321025d145b77df04c40ceb88ea36828755f8275dc5fecf19d3ecccce2d8198c3407cac00d2496b000000001976a914afe70b2e1bf4199298ed8281767bae22970b415088ac0000000018e6b092e60528f48d1b327512206a33e84d90253fe177d9af951d72b2ef4ce047cf5ef8dd7a705c5668376f390c18012249483045022100fe1b79f38ca4b9dc2fbc50eac6c9bf050ae5a3ee37da05b950918230eb0a8c7c0220477173b60ec00a8b4b28d5db9fc5d8259eea35f91bbdd60089c5ec1be7b05f3b0128ffffffff0f3a0222003a520a0514ef0d40dd10011a2321025d145b77df04c40ceb88ea36828755f8275dc5fecf19d3ecccce2d8198c3407cac22223764396a4e79716835694b555a566e516a6d7a6d4541513878444b777a4536536e673a470a046b49d20010021a1976a914afe70b2e1bf4199298ed8281767bae22970b415088ac22223769536a6e57436f41556d4a347656584d6b61457561736e5658537a5a7166504c324001" ) func init() { diff --git a/bchain/coins/monetaryunit/monetaryunitrpc.go b/bchain/coins/monetaryunit/monetaryunitrpc.go index 1123ebd4b6..41c53390bf 100644 --- a/bchain/coins/monetaryunit/monetaryunitrpc.go +++ b/bchain/coins/monetaryunit/monetaryunitrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // MonetaryUnitRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/myriad/myriadparser.go b/bchain/coins/myriad/myriadparser.go index a01a4e4d9f..9783d1977b 100644 --- a/bchain/coins/myriad/myriadparser.go +++ b/bchain/coins/myriad/myriadparser.go @@ -5,9 +5,9 @@ import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) // magic numbers @@ -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/myriad/myriadparser_test.go b/bchain/coins/myriad/myriadparser_test.go index e8b66d1121..846681b7c0 100644 --- a/bchain/coins/myriad/myriadparser_test.go +++ b/bchain/coins/myriad/myriadparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { diff --git a/bchain/coins/myriad/myriadrpc.go b/bchain/coins/myriad/myriadrpc.go index 938eee7725..bb4e5b77d8 100644 --- a/bchain/coins/myriad/myriadrpc.go +++ b/bchain/coins/myriad/myriadrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // MyriadRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/namecoin/namecoinparser.go b/bchain/coins/namecoin/namecoinparser.go index aa16a9a4c1..30e1f705b5 100644 --- a/bchain/coins/namecoin/namecoinparser.go +++ b/bchain/coins/namecoin/namecoinparser.go @@ -5,9 +5,9 @@ import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) const ( @@ -34,7 +34,9 @@ type NamecoinParser struct { // NewNamecoinParser returns new NamecoinParser instance func NewNamecoinParser(params *chaincfg.Params, c *btc.Configuration) *NamecoinParser { - return &NamecoinParser{BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c)} + p := &NamecoinParser{BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c)} + p.VSizeSupport = true + return p } // GetChainParams contains network parameters for the main Namecoin network, @@ -80,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/namecoin/namecoinparser_test.go b/bchain/coins/namecoin/namecoinparser_test.go index b0e3cdc78a..e79a91cc90 100644 --- a/bchain/coins/namecoin/namecoinparser_test.go +++ b/bchain/coins/namecoin/namecoinparser_test.go @@ -13,7 +13,7 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { diff --git a/bchain/coins/namecoin/namecoinrpc.go b/bchain/coins/namecoin/namecoinrpc.go index e891a87e2c..b8d2ea94ec 100644 --- a/bchain/coins/namecoin/namecoinrpc.go +++ b/bchain/coins/namecoin/namecoinrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // NamecoinRPC is an interface to JSON-RPC namecoin service. diff --git a/bchain/coins/nuls/nulsparser.go b/bchain/coins/nuls/nulsparser.go index c29e8950bb..3c3f97d83a 100644 --- a/bchain/coins/nuls/nulsparser.go +++ b/bchain/coins/nuls/nulsparser.go @@ -11,8 +11,8 @@ import ( "github.com/martinboehm/btcutil/base58" "github.com/martinboehm/btcutil/chaincfg" "github.com/martinboehm/btcutil/hdkeychain" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // magic numbers diff --git a/bchain/coins/nuls/nulsparser_test.go b/bchain/coins/nuls/nulsparser_test.go index 74fc96bb8e..d0a12af50c 100644 --- a/bchain/coins/nuls/nulsparser_test.go +++ b/bchain/coins/nuls/nulsparser_test.go @@ -10,9 +10,9 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" "github.com/martinboehm/btcutil/hdkeychain" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/common" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/common" ) var ( diff --git a/bchain/coins/nuls/nulsrpc.go b/bchain/coins/nuls/nulsrpc.go index bef16e56ca..3c73bed8bc 100644 --- a/bchain/coins/nuls/nulsrpc.go +++ b/bchain/coins/nuls/nulsrpc.go @@ -16,8 +16,8 @@ import ( "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // NulsRPC is an interface to JSON-RPC bitcoind service @@ -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 5558f74828..3b823a33ea 100644 --- a/bchain/coins/omotenashicoin/omotenashicoinparser.go +++ b/bchain/coins/omotenashicoin/omotenashicoinparser.go @@ -11,9 +11,9 @@ import ( "github.com/martinboehm/btcd/blockchain" "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) // magic numbers @@ -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/omotenashicoin/omotenashicoinparser_test.go b/bchain/coins/omotenashicoin/omotenashicoinparser_test.go index 1c6d8d29e7..1bf03c097f 100755 --- a/bchain/coins/omotenashicoin/omotenashicoinparser_test.go +++ b/bchain/coins/omotenashicoin/omotenashicoinparser_test.go @@ -14,8 +14,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -151,11 +151,11 @@ func Test_GetAddressesFromAddrDesc(t *testing.T) { var ( // Block Height 600 testTx1 bchain.Tx - testTxPacked_testnet_1 = "0a2054af08185cf5c5d312ebd9865b4b224c6120801b209343cfb9dc3332af28a2a5126401000000010000000000000000000000000000000000000000000000000000000000000000ffffffff050258020101ffffffff0100e87648170000002321024a9c0d55966c7a46d8ac15830c6c26555a2b570a3e78c51534ccc8dadc7943c8ac000000001894e38ff105200028d80432140a0a30323538303230313031180028ffffffff0f3a520a05174876e80010001a2321024a9c0d55966c7a46d8ac15830c6c26555a2b570a3e78c51534ccc8dadc7943c8ac2222616e667766545642725934795a4e54543167625a61584e664854377951544856674d4000" + testTxPacked_testnet_1 = "0a2054af08185cf5c5d312ebd9865b4b224c6120801b209343cfb9dc3332af28a2a5126401000000010000000000000000000000000000000000000000000000000000000000000000ffffffff050258020101ffffffff0100e87648170000002321024a9c0d55966c7a46d8ac15830c6c26555a2b570a3e78c51534ccc8dadc7943c8ac000000001894e38ff10528d80432120a0a3032353830323031303128ffffffff0f3a500a05174876e8001a2321024a9c0d55966c7a46d8ac15830c6c26555a2b570a3e78c51534ccc8dadc7943c8ac2222616e667766545642725934795a4e54543167625a61584e664854377951544856674d" // Block Height 135001 testTx2 bchain.Tx - testTxPacked_mainnet_1 = "0a20a2eedb3990bddcace3a5211332e86f70d0195a2a7efaad2de18698172ff9fc6d128f0102000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1803590f020445b8075e088104e0b22c0000007969696d70000000000002807c814a000000001976a91487bac515ab40891b58a05c913f908194c9d73bd588ac807584df000000001976a914a1441e207bd13f80b2142026ad39a58b5f47434d88ac0000000018c4f09ef005200028d99e0832360a30303335393066303230343435623830373565303838313034653062323263303030303030373936393639366437303030180028003a470a044a817c8010001a1976a91487bac515ab40891b58a05c913f908194c9d73bd588ac2222535a66667a6a666454486f7a394675684a5444453847704378455762544c433654743a470a04df84758010001a1976a914a1441e207bd13f80b2142026ad39a58b5f47434d88ac222253627a685264475855475245556b70557a67716877615847666f4244447565366b364000" + testTxPacked_mainnet_1 = "0a20a2eedb3990bddcace3a5211332e86f70d0195a2a7efaad2de18698172ff9fc6d128f0102000000010000000000000000000000000000000000000000000000000000000000000000ffffffff1803590f020445b8075e088104e0b22c0000007969696d70000000000002807c814a000000001976a91487bac515ab40891b58a05c913f908194c9d73bd588ac807584df000000001976a914a1441e207bd13f80b2142026ad39a58b5f47434d88ac0000000018c4f09ef00528d99e0832320a303033353930663032303434356238303735653038383130346530623232633030303030303739363936393664373030303a450a044a817c801a1976a91487bac515ab40891b58a05c913f908194c9d73bd588ac2222535a66667a6a666454486f7a394675684a5444453847704378455762544c433654743a450a04df8475801a1976a914a1441e207bd13f80b2142026ad39a58b5f47434d88ac222253627a685264475855475245556b70557a67716877615847666f4244447565366b36" ) func init() { diff --git a/bchain/coins/omotenashicoin/omotenashicoinrpc.go b/bchain/coins/omotenashicoin/omotenashicoinrpc.go index 947f4dcfb5..d35f6ab92c 100644 --- a/bchain/coins/omotenashicoin/omotenashicoinrpc.go +++ b/bchain/coins/omotenashicoin/omotenashicoinrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // OmotenashiCoinRPC is an interface to JSON-RPC bitcoind service. 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 d59fa545af..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" @@ -11,9 +13,8 @@ import ( "github.com/martinboehm/btcd/blockchain" "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // 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/pivx/pivxparser_test.go b/bchain/coins/pivx/pivxparser_test.go index 1b325cfc9b..94e3f1e164 100644 --- a/bchain/coins/pivx/pivxparser_test.go +++ b/bchain/coins/pivx/pivxparser_test.go @@ -14,8 +14,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -122,15 +122,15 @@ func Test_GetAddressesFromAddrDesc(t *testing.T) { var ( // regular transaction testTx1 bchain.Tx - testTxPacked1 = "0a2052b116d26f7c8b633c284f8998a431e106d837c0c5888f9ea5273d36c4556bec12f501010000000188557c816acd0a61579b701278c7dde85ea25d57877f9dbc65d3b2df2feacc42320000006b483045022100f5d0e98d064d5256852e420a4a3779527fb182c5edbfecf6143fc70eeba8eeef02202f0b2445185fbf846cca07c56c317733a9a4e46f960615f541da7aa27c33cfa201210251c5555ff3c684aebfca92f5329e2f660da54856299da067060a1bcf5e8fae73ffffffff03000000000000000000f06832fa0100000023210251c5555ff3c684aebfca92f5329e2f660da54856299da067060a1bcf5e8fae73aca038370e000000001976a914b4aa56c103b398f875bb8d15c3bb4136aa62725f88ac000000001883a8aacd0520002880ea303299010a00122042ccea2fdfb2d365bc9d7f87575da25ee8ddc77812709b57610acd6a817c55881832226b483045022100f5d0e98d064d5256852e420a4a3779527fb182c5edbfecf6143fc70eeba8eeef02202f0b2445185fbf846cca07c56c317733a9a4e46f960615f541da7aa27c33cfa201210251c5555ff3c684aebfca92f5329e2f660da54856299da067060a1bcf5e8fae7328ffffffff0f3a0210003a520a0501fa3268f010011a23210251c5555ff3c684aebfca92f5329e2f660da54856299da067060a1bcf5e8fae73ac2222444b4c33517a43624a71724870524b4148764571736f6d7344686b515076567a5a673a470a040e3738a010021a1976a914b4aa56c103b398f875bb8d15c3bb4136aa62725f88ac2222444d634e45393855667571454b32674746664b4234597057415771627748524154484000" + testTxPacked1 = "0a2052b116d26f7c8b633c284f8998a431e106d837c0c5888f9ea5273d36c4556bec12f501010000000188557c816acd0a61579b701278c7dde85ea25d57877f9dbc65d3b2df2feacc42320000006b483045022100f5d0e98d064d5256852e420a4a3779527fb182c5edbfecf6143fc70eeba8eeef02202f0b2445185fbf846cca07c56c317733a9a4e46f960615f541da7aa27c33cfa201210251c5555ff3c684aebfca92f5329e2f660da54856299da067060a1bcf5e8fae73ffffffff03000000000000000000f06832fa0100000023210251c5555ff3c684aebfca92f5329e2f660da54856299da067060a1bcf5e8fae73aca038370e000000001976a914b4aa56c103b398f875bb8d15c3bb4136aa62725f88ac000000001883a8aacd052880ea30329701122042ccea2fdfb2d365bc9d7f87575da25ee8ddc77812709b57610acd6a817c55881832226b483045022100f5d0e98d064d5256852e420a4a3779527fb182c5edbfecf6143fc70eeba8eeef02202f0b2445185fbf846cca07c56c317733a9a4e46f960615f541da7aa27c33cfa201210251c5555ff3c684aebfca92f5329e2f660da54856299da067060a1bcf5e8fae7328ffffffff0f3a003a520a0501fa3268f010011a23210251c5555ff3c684aebfca92f5329e2f660da54856299da067060a1bcf5e8fae73ac2222444b4c33517a43624a71724870524b4148764571736f6d7344686b515076567a5a673a470a040e3738a010021a1976a914b4aa56c103b398f875bb8d15c3bb4136aa62725f88ac2222444d634e45393855667571454b32674746664b423459705741577162774852415448" // transaction with OP_ZEROCOINMINT testTx2 bchain.Tx - testTxPacked2 = "0a20599d5d797a4575eb25e1c291c0e7630bd6fdc0e6ec5fa9b14147f929a4e41bf212ae020100000001b56a0fe242a8de7dcbb58ae1009e44e7f2ec25a65eeb8b815cf53393309741ca0100000049483045022100cc208a59341dca98207ec8a4a42c014d435192694a77c69d40e51467800c0a0802205ac1782d4ecefa260b33340d92c2ab2396b43c1073a67b4180aa8ef2aede8af801ffffffff0200e876481700000087c10281004c816f5ce1eeda911203319a256e8560c8dbfd47b569ff32c27559bda78854e63e49718ce43036e5120dce357b5630afd745d399f91e675a921adbb45224a6661656217fcfe32396fb25609b724646759116326964f2f1f7ddb7c340dc24be2b75a0a9dc05ca2fdf805c03c7a04d972456beb82a51de73d8842b39a553919dfa5d8e003e98dab7210000001976a914dda91c0396050d660f9c0e38f78064486bbfcb2c88ac00000000189dab96cf05200028b8dc3432770a001220ca4197309333f55c818beb5ea625ecf2e7449e00e18ab5cb7ddea842e20f6ab518012249483045022100cc208a59341dca98207ec8a4a42c014d435192694a77c69d40e51467800c0a0802205ac1782d4ecefa260b33340d92c2ab2396b43c1073a67b4180aa8ef2aede8af80128ffffffff0f3a93010a05174876e80010001a8701c10281004c816f5ce1eeda911203319a256e8560c8dbfd47b569ff32c27559bda78854e63e49718ce43036e5120dce357b5630afd745d399f91e675a921adbb45224a6661656217fcfe32396fb25609b724646759116326964f2f1f7ddb7c340dc24be2b75a0a9dc05ca2fdf805c03c7a04d972456beb82a51de73d8842b39a553919dfa5d8e003a480a0521b7da983e10011a1976a914dda91c0396050d660f9c0e38f78064486bbfcb2c88ac222244524d38546169593338716348626764797470386f455472656f62424c48747065454000" + testTxPacked2 = "0a20599d5d797a4575eb25e1c291c0e7630bd6fdc0e6ec5fa9b14147f929a4e41bf212ae020100000001b56a0fe242a8de7dcbb58ae1009e44e7f2ec25a65eeb8b815cf53393309741ca0100000049483045022100cc208a59341dca98207ec8a4a42c014d435192694a77c69d40e51467800c0a0802205ac1782d4ecefa260b33340d92c2ab2396b43c1073a67b4180aa8ef2aede8af801ffffffff0200e876481700000087c10281004c816f5ce1eeda911203319a256e8560c8dbfd47b569ff32c27559bda78854e63e49718ce43036e5120dce357b5630afd745d399f91e675a921adbb45224a6661656217fcfe32396fb25609b724646759116326964f2f1f7ddb7c340dc24be2b75a0a9dc05ca2fdf805c03c7a04d972456beb82a51de73d8842b39a553919dfa5d8e003e98dab7210000001976a914dda91c0396050d660f9c0e38f78064486bbfcb2c88ac00000000189dab96cf0528b8dc3432751220ca4197309333f55c818beb5ea625ecf2e7449e00e18ab5cb7ddea842e20f6ab518012249483045022100cc208a59341dca98207ec8a4a42c014d435192694a77c69d40e51467800c0a0802205ac1782d4ecefa260b33340d92c2ab2396b43c1073a67b4180aa8ef2aede8af80128ffffffff0f3a91010a05174876e8001a8701c10281004c816f5ce1eeda911203319a256e8560c8dbfd47b569ff32c27559bda78854e63e49718ce43036e5120dce357b5630afd745d399f91e675a921adbb45224a6661656217fcfe32396fb25609b724646759116326964f2f1f7ddb7c340dc24be2b75a0a9dc05ca2fdf805c03c7a04d972456beb82a51de73d8842b39a553919dfa5d8e003a480a0521b7da983e10011a1976a914dda91c0396050d660f9c0e38f78064486bbfcb2c88ac222244524d38546169593338716348626764797470386f455472656f62424c4874706545" // transaction with OP_ZEROCOINSPEND testTx3 bchain.Tx - testTxPacked3 = "0a20b65181decb00e684fef238776a0a129db4e1ffdfc454f6ef323e5f7a8deae6a812e1ab0101000000010000000000000000000000000000000000000000000000000000000000000000fffffffffd8a55c2028655e8030000dc8ce67bfe1851477371a9ac40b6ae0cb8571f6e2d5285855288f6079f1ce7239ee8c85f465b1820058b79554f41af297e9caf95ce0084b7c35dea0b95e15a2fb9f8e62c5427c1c36120cbc1fc11ff344909079335209c6b84b45a9211cac960f64e9432ba5eb6e4ecb2068223dfe3d85b345da17bf374f9140c9577c148bcc431c9ec3c7d13bd2363dba821381ed9fa0614416261e88330b3e74c40e6561310eab3f26f092e72f3cab761f373d02680dfb52937bd9515be242f6573754f8f665523cce3bd606c8ad190954f8181577fd0efe7cc64b711d03774958df4a5211e44870302056557777951d7ff8c002161a6a59e979f05469cb31770bd484be6525625359979220eb7e9912e835065fb00fd000216aefbae3525166510814d1636b76b0d48ea3cd54a3a17b136a84340989d75f74ff952966830e4c0d59daa006d5a7190978270ee9475a0778afaf002cdce7efdbfad630f72838b5c4a3b538ba61b94bbd9e353437a50725af5f16fbcbf36bb34e7da54e5c24dfc90b545f95c973877bfadc2703ee10585a1fc1c97d7377bf41c9cbcd5a313849a3c826e7c1301083694e6dc05f46899a901ab4a8d7f6b3600df280157fbef6eca4c28fc610957a42a9acf7c4d7f9846ab6b9b04fa6abb5fefc168d45f10078b97d4d6a39638588a1c19e1bfc472657861a902c2d52cd32fb0463746f649ae88bc0602dbf35816fbccf91dc249be809160cbc7f8b6702d6cc5b81fdebd283231f40758afc899f6fecedc51dc4e5d09cb8961092220541f75ddad45680ea92b4ee78c29f58c197a68420bbb25b450c72d02d7249f7facf9927378620eb36fbf9b4ccbcb55627eb9cf905b4a4c65fcb77a537f642f10901b6e94afa37e4afb0d6d91194454a9c2dd8ef8fe4316f8594c7822a7d58cab09657cf501da5be5a44f947bb957b71e4291a7fc60cd5cef9f0676f7c89123c7ff1ae2e6dc001b6f19785534e207fed2bade8597541b13714284f67d6986bc616ef1b0adbe415242fee85acbf482a6a48b3f142ef7ddb5dd1c97a4b0c53c6ac7aceb8c042d9c9ada1bc986b8c276d07fbf8512a3dae6a357fc02b167eb85000040e8693e45fd000200e23abd27b258f9827ed58545a507bd465e255e1156610da314bf7df68b6b55129df84c7b19e362751ebb9beba10790c9c26c5ddc7f087258d81b006c0d2e92be0178bf5edf6e78e89f73cd97746afbb2551dbc97eafe32ae62e7f9ebcd14ad69faf74d2011d16f2c50775f4f499c87c3c50d9d5d486394c2a7f462675d2a4885493332e0610a78fc0c8b08eda42e4bfe93b8c7f80a911a7992a1deb7cca2e40933e1559815688d4e5ae5e58d706bc513e5108449a8393928b5b77ae73cb03fe212c6375b6c5e61fce9db16360a147e7f7fcd49e05a99711d4d5799be77f7e39d9d1397388d6680d4931b48798ce013256a586781ba80168bae63bed4d150b64a73f7d0ab0c9ebb42f5d4db40eeee303783249af4bbf334c660f8c084ed9a2e5fff8be230940a4a08b59418676ef005192365e4e67757288791ce4992b903a31537596cf6dad0be2af2418a6b9cc2c33e99d874168f6a29df189a869b16eb5d24400ab30e4eca9274114d646aaaa8bad45832b6c0ded2bfa698939a8af9d0af380d2afc58966afe0f45483ecad0f114b904cc2fafcf470dd4fb8f193795e8afc3243b4c946d5eac82babac6feaf4ff10bf53acdf8347fb9fb7a5ac4efcf160f0a7ef3576f439404a3078ea092f46f408a955c965344023d847fad7374cf145cadbc8348eeb2c5aa999ebeeb8a5548bb14e0092b184caee354020c19d66cb213fd0002729bdb186c4c494e17fd6effe29cbe7fbc539caca738d54f9ffc6e52a35a27da134192e2f7f4ee2a86af281b78670e662677e97a1ef008f10f42349fa83bf7841d88b1a457d38164a383ae9c6b974137d58216f22d135d30b9d6e7a74952a7e905f385141f4df088415d704cc3b03b2cd600cb5507f8ea1b53fc0e73031a3946c0d6269f020c9c26a3be3bfdcd37f8d3b9fd42538ebd72029fa0bd8eb57a4fe6769e1b43b5d5d7be311e12e52ee9dad67aa988dedc80ad616d7540381993d9de91a7fac6d08e4414254b9d1d72940fec032833a6b1a5605f4b62c47a86d70dbec5ec913d0a613d438cad385fedf24566bf79edd17238e55421520b95772224623f145100e663b2ba20161784f688afcf07a900ade1d48060d21be9ba9297697891c2584cb99a44868efbdf65178592ecfbadc92f4883662d6b21b7f266eb21815c7401b8e7da061e3258dd685f8cf65f2c2e407c913f85d053b05f6f92ed1299186632ddcf175ccbbc933044bdac5e10916917dea1146f77a8ba4b4fc8ce260b5deed395ecae9b81baa6b385fecca5d2982041c131ce02a1dec517ad2d459434aa3a514e7a4c6c1362401b1ab62b4c89bd7705d5072e0be5250c60c2fdd946bc73050d3b8bcfaa73165eee3660063f279e824d1e15f87307a40bc9e1ccc0f7d7087ba84fe9275742455241b61d3687d23eb9d7a9cc18072ed8be1492db46a454464090750eb0a393499a73d23f4c552ec6ae425a77f97d4285a7f287066b2198bfaa99073da6f4009755e59838e48cd5fe692962b87da3ea7e14b34b352fb2a4673eaaaa594a094610bd0cc566acafc21891b7b0c2470bbb338f579231e01c064f275c5ac9c2748cc50e7f2e36f2768d2a59c22d14b6b9a431f7772e716731c55ccbf086187fb15bd07a5af3040d468d4088ba9e6383f1df6dead9384758f2da81ee96370d9055ce5a0db56bbdccb57ab490f42a01e083b61c5157b3c00e2011dda865c7294cdfd2be5c03a1a36a4deda9cf03b500fd0101b3c97046a717a2f38fe2265185f4411cc68cce6cf9885a7a8fe6292eb9e3eca69fb6249774a8c82b888d22d5fcdd549846c3ebcf054c6bf07aa4d6b1c0d4d9bd8e16501f65373ceda249e9c760848fc86ea92fae7142d211c8bb4a287c91eb08cef7678ef1f445f76f81d464eb1d29ce5c6d6286d73d49cd0ef03b65376eb146a3ff69a487ec90b53c11cce613a586f22cd56fd34df6f7ad64fa3c68a6ae5c9dad99d4a3d2b0974ea1f627be4f0153c7b5fe472d0c562556c4d8d1c7c592bea45bb7886b74f8639f9487b2f6aebf4848b5718f2ce65b8f5a4efcf140e2857bc9c503f0058f9b6af16e75f2d530fbba8979f81569e6cc0bc04a54e30de4e03a9300fd00019b881d73a78b86771d41c0c2a9ebdb3899727f33d2d4d81dd27b6a00b4c7db265999b8442800750abde7cd97be0c691ed06b5d40da115546d90d4803e82c61d43eb5e2bc684adcf180be47660870921fdedb2ce43f33564541fc0debe175c6c49bdfb51378902dc709594b9b7d34d0af70b67c3c608aeb5185e78c1c39cc3080b71a36115f623a07a4e1a3e1f3e17b6f9f695f1a1acd9dd1319d0a0d67b337e64f720e5168c09196244bc71b083f302e042be19b6aa1f8ad61755f4883c3a1ae615252b884ca3cca5e18a023ad6725f08f9e0ffd60e7a73ccd29afc910d60dc99c06f5953c3e398ef615fca45f6a83f8a0be653d31a7e1a1666cc7334d9cad51fd00016f7efa38c8f9f478a6ea1217d8be9b7f0b01c5d1fa483c26e403eb3a4875aeebbd0e7eb8aab8472a5bd80e8be38df13526042952f813b71f8aeb4a2281bfe5e5d9ba70f7e4c9f477706da922899f505dd172e260ce5f008b59c0590d498ac50810e9a38d35f1a2ea4e9ce8f77e46d0b5604dbf94629cfb8b65453a0295eeca9d992365309b7956481a8c5080510a09183bd3358fb26933e15c83fe2ca6d186e631d72889a09464f5dafdc8a93dd100329071e52beb522bef1af0fbae0516ad3b02011e19a2b2924791b3f22679b78039c8356c0e6676e2451487f056d0cff064f55a992afba08f59af7606a809394772ff85c4c40673dd54eb30020c6a5cde3cfd0001ecbc47845e4a1f3c05c4e9d0a47e5e8996326f48d7ee1bd0e432c5f33fecf8a94feaf03f2da65525bbcb119c7928456c28f31c183a21af1acfcd9615669cf47a077f861f694bfc1831f1a71ab66540906c62b85274f63abcc53a37e3fccdc8563ca2153818b6b796473848d765d1c81d4e6f78ef8fce804184e04664c5c6af7c3abe4d92aac3f6ea99b84a3e53a7bf7679c26dc96804ac9e1443b054e3c55fe89315106a14646d06b84122861ac0ac27fe64fbee3c9807b0264a90eb9602187f4df2cc0c62b025ba70bbb30a7e43d533f32546d2e6f97537bc2d623a7f545676a37cb511614037d77fe21a35ce4a2275e7ea1d85b7bc19e477c61033f5effe7fa5d8ff48e2873845157b7a29718e6692704a9d864301fa254798555172c6277cce4ed5aebdff9c27a1bb37071677c16da15d6c1547c5b708e1988eba68befff1f1743342ad7cb89ec8731b567198953d965ef6e395d38b410a326ef3e262937c763179f22a076d17f848dc4e36be38d838d5788f121d771f23209a0d50fb3d016c5cbc47cec31069f7d22bd78691662d8b609932b9533bf828e213e5ed4e61f2b80f05acbc00fda0017bdc59c8a6a23d261cdf1dd10e91523503c3375a803755c29b6a84bac626708b7db937ed39f4053b5c6376b3988fc9388cc64dc07466c67704a32b703d5dd86d8fb8597cc2ff7ca190044c66638028855f29fdf2a0943c64125e3ad2487de6928bc34088957eb32d843613e6b588a91cbfd8c37c24ba655739cefdb203bc3061ff93498160c7e949823b0947c68b6c51dcdf038c538f50413266680ee23817ed9cb840e0094f6fd2277012aa2c6f82b086242e6332a4e00bb9c12c153dfea9340e681e63d72551f8830b2bb2587e5937685252928894bf90bfa174f62bf7ccd43415a094bb4142fcfe639c62f6eda0e4d7ee033f49f51eaa6a35b0f7f400992d8367a275e9d018a547430083c41a35ef543bb92e159efad39c5b84d127bc68dd581c308afd5f81654ffcdb3317dd6e21d5251916214e873c83b6ac197dbac6c1b93d79b9dd5da090be204b9765fdcf662c9296295610e42a0570a1503dd67c44e17936946f3a6ce61f82b13cf00a0b47d06f2cd28651b6af32ffc58304593d5ce81159ccc952ed980f93182fb468ebccadae4dab4565d64a5bcb3aec7a09d681fd24016a4905a3a143e4d61b16898fddbb0f9d7602ce865716b62ae4f9a37e2f6ab89de930e066db6f4a8667ccc4e79e7ac760642c33e5f24266540c9b4fbadda4c0aa1b6a74d02bdf2324ebf9598d8ba918438de1e3343be0b057925bdd52304581ef621fd085dc55cf8b45d605cb0b60047bfa935c2968d554753a615e75f24086b4e40508ecdeb411ed26c007a1110f3e73f504d7fdefb275cbb59cf9cd68bf4784b8845467fac90275f3bbcc2c14a87fbbd7d111441d6ba0833b9045db43975317aee170242b291f8b07254d395472bd4b67db7576bcf2460bf0c182f745a6cbbec3f680b7c6e0a85308bc3af8af3302355757a77a2fe3f98350e4ea1b3074e37a638c630d529141843583ba4b802e089e0a7ecaeeeb42079e072e64fa5251782cbc67ba9d46e4c7f502d44a06e5212d09f4dc5bef1f1dfc376d4a042f608b860971c44caeb3735cd57e19401314af06a73180918af7693ec5204b3f858806e6919af05a1a8c6daea2ee3f08fd2401c082f09f7042a7a0b6b484287050a15f5d8c1011c200d42eb51aff5a484fe1de9feaa264ed3022b6f5b1b54a4a316d3d7e2635210ad83e2d3c497bed46f417ed22804682529b034925dc785a2d74361d1db395d1681d71bc1b0635908ec3e92f850577f35912fe5c173402e6e2ed8003c64a57bb65a2013014a3ce14ccc725f733a50457a696396cac0a551cdda03a2ec81597031feaddd801a9bceaeb5f862a4cdb3eda06dc317a96b29c27d78dd977cc6f25d62bb967814fd1d7e87c675042522c904fadf1cff80289374de8f98df511de975011d877058aa7ea9cdf186ccfa5cfa5258e581ee7ee73e16dcfaa82f079a16c95e6ca4f49c037b2423c12006b549a0b80c5dc9b93685fd2a4bda99e5aea74eafe9d28149e94adc538198d459c7a9a45a1fd24011c69aaa26aa94954fb7dfae2d63eac286b4979e7d513eac8144a7bccc34fb298b7c556a82e7450bf590f1ae658c474ac7c12b3fccaec877b6bdd11fd9655a27b7b69b922b1629a24a8d7f81a6827dd22cbab62d121f5cf96d59be904022360c5bf04d2435c52541ea4d7f932e19fc471a0afb38f2e84668d974c57c963346d790a670a699ff53b5557def1ce9650a8624bb2065f9ce99d6b5361a1b39629040fc897a5d0a07816618840f86601508e90198de64d7091a8bda406009948fa9ebbf2f56adc66e057ab23152504438062867c4237ce2b99c020add16bf66a0c06a072c4fabd9b9fd1146b73366535d934328188798ccc18ad37d30e06a39b8e14468d32820d912359323b7d474dbf507e894a448a4e921f70d1fb4d4ac03b178ae157814004fda0015c202cbc492bde212955236761fcad7de9ec8932946b5352f6c35a242d39b354a1f96a706bcb84174a5b11a7e51cf0adeec9c1e5c88a3f8f32ca81977db668650734398e6feb45cbe0ed5c5d8f77cfa67b78f8c7c856629e6731321126d12ebe70603fe28d4281f5c9165e60749bc8688d7ff3cd509d958dcd1ac7b068a3548af0a3e20b984bf4406a6c6d55c674bc83240757e3a0515bbb626b6b6b863fa7029c5043d67ebcd531601d39b8b6b7e507a176216a264fb80f574d7a6a587d5ebba7c355007630fc49127368f6b672bd12fba956db75f189bd438f5034badc04d396b04a021608a7888434eefffd082efb0c3196698d1b015b42eb6f5a2123c085d37841c0cd6c7a1d77c55d61d76426758cdadcad3d7b1037b5d94c29962d4fe218c9f98c89ecdd9a50a48bc534bc167d536e11906bf594876aea01683269255701b705ac454b250abd45d10f4f8b881c6f4a360014b450132d750099100e15c70ef65ab0eeea340604f3c7b3eb3c51292036a6cc2843989ea418631fb595ca9d7e23a88a3a7d0a8dd5ec1212fc786dcf7107214d5c4d3f2cffffe2b1fbef09816033fdb616b9318b80b32f045ec1bec678dd1500480154ccdd87365d602f0126b57015784abee6fdb9b7f22ba135cb882dfa738baa17233fa53c68d35e4102f185c07a1310b710ff2d63db7238d36487ab504e64e239d0641137cd75cb282e831621f101663efb2f9de2520049c0d08a7c965477fef9d575ba31e101faaea20f96e40046132cd3aa981e8f360cb6a9bac68844d07c7d1741e56b104f634fa1a0c31d64ca4526de4f1754effc40aa04b9dfdb6f53ef77540d66fc9d0a4058fb46518433a1467a71873a03600b47c81e0d452cc44728e3a625235c84f7f62d753faf91fdfa1aea056925168616516e5c4357a7e82c94127fe8a4626c868bb7bcc13a0422e15e31c82935a4e1b9cac496bb456d1318e2e3e704627f1dbe646f5f1590283833193cb03f28e00f5020fbd43c5fdc39fd35498cf4fb7471417e814974331500d8e4ec8af34af39b457d20843d897d0e015456600325ef702c1bdfa7bbb3f2e7a74dcd8fd77beb3df4022320108d0f48a7a9b2a32e1587eb01094e20cb7c002d3de08f481a1d7cdc6b3f9c7c20185bc7ee65a12aa6003a58b83aa90d90baef64c7d324c662ea5138fbb4bd063720f8f9111e20def54a90755537e44fd506737cf1fcc4d0b320bef16eedbd750f8b2002e3af2f477e9d2912e50e4f03e43742c19e6e94c1ccf84ab04f0eff342bfc1020a544d7e745fa08a740418aa6da398bf10870a860427dcdbb857b6b41cec8e1342059eabb84637e61530039918cca60b860ef24c0df9e586b7ee9ce89336d5f1f7a210f0385ce2ad9f83a8534ab0325a42495a9cc4cd4774c9e8910bbf4e7192c2e98001fc3aae0957bc822f2aca9be874e1493a0dbad2ceaf959996060a2bc1781c23520cd178b0cfdcaa92929b9daef0c4dc752225792454327bc351b63765208972820203954fb6c5f5fa020f757c1bac415fec7e1bad3b7c19a93ba5ba53dc12f5ffc6720e7572f4709fdb74cdf2dbbf50a7bec9c10ee302cc2c5dd878bd109d1c87bfe3e20883ea4bb25deffe4bed0029e066230311cd71a4beafa608d43d615652cf9c146204c98a01cfce1f0ab321105b2f2659d375f691e2f6cd9eb821adc512718acdd6120d5f64f7950ad04508b36baeadb52228ed3a1139512125834c1f449b2a9613d67204550d2bb9a9333d567b6c2ab154f1fb4bde51d4b5c50307989ed07100095485720109b0b89090c8a2fb0c51f3f9ca1eab0e36b4d9200396e7958523e57b705d11b209195a60cb9f034fced26b3336b0c49872fa13d56cc410f59463e4f312c93423d20f52bbd6ff9d724752b5ecdb148a47e86c1378111d76e77a2f4434816e325c62920510cf87382f2a4c1417203c4e17511170ac616fca2caa49b521dd721a8183f1120ee7ab5b0de3332ec1fb81c2d5baaf0509eda6de26147b081866c2a9aaa3435882011ce790ec01637ff533a68dfd8fa22733bd7e4fefe18a5796e9bfaf996adf54b20509ea869f679c1ae8b074619487fedad28874cf9c93d01003e9dd0df2f45b66b20380577ba031eb78adb2c71bedc8154056b157ae0d01203c19d7df420a424f71521d04f8ed62e7175b7e70d7f92dafff586e5a60be02901f9f67e97374bcecfe7830020c6be51acd068843922b23415a1c7b39f4846da7584a496483a36612dc295968c204e43611bdc897913427fae390deb145024077c5808238d960f93c62a62f70e6520e2f6ace6ca1e8b6b73dc2fc3c43f3d1e740f1c663e3e0ff9415b2d282e1d377420bf6b145630d553e8c6e40d84626f01964e3d6befc33ac284263ee2c35d76003721a9d895ebe788be0a6988027ae6b33a26bee33016d8f4b64255f320d34deba4900020c8bbe181b5482c9352b9d607eed48dde4a2759d765bc2da53cbc1c03d68da30a20818a0a41e03fcaf75f38766bfa81738f0c8ec2fabed7142e998e7b55e014300120b61bcde758db04bc126bc67a1c87b9fb4f8eb2c3958b9e4106306508e2b22c5220cec0548ab3977bd4bbc802483f0c2ce3b1d6dae43bc0f8c79167e4a76ee6f7522007f74fab71a89f6b94bdc8b5128de93ef2be214d04fbb8ac8c2832e1f2ea6371213c26fe406e69b2060b60c6d0eb7759fe85f19cb2288344239f2a398f04b2b8900020689efa2cc2baa15ce24852b1f71d08200fb1f2ab8e10be0c4db8c8263a85032220b1f2737db4e6e55370ce004319e7c5e137f3567bc4d5603e4ed1f5c636db6a6b206dc3dc994eb2ed126f1a76892117b18b24028dce73ab6d35924ec69df9bc361121fb6e92175d959528d8326cafddb4ee94fb9ffc2d9c8a413898f14cec99d4299a0020349eb2d5c10d69df3bfc41f99d6cafff21ce69323637565e1cefa60173ad0d78201818225053a5e2d51745c5002281935d4dca8ad82ff76c5ca2dee6be84fb767021e8d31f169295ce6052584692ed9e4da87e637ca0b52455f216d5036c55d45c85002100c51c6bcf08c0a3926b2be93b47d0ed8955386d369ab8d6849957efb23904a20020b4b64e35cfcbde2461db3ce57292361aa30d136b0e7bbc766f4aa3ab4a72b66821e802f9eb8168e002b64b2b7ccdaba46e73e5c422dda18311788b19eec7af5782002062e41e97d6c8f4c6ac4c0f978f6f8488c92a51eb5793ad109abee4d6c009256920009f7f1755459578897b027bc2b282466aa1d3b9b0983fe4cdf51c9090548d5120760bc9a32ab7facf7f3c1d1aea1b6cab28bb65276e269472cb24ded949256c0121ea8ed0306eb39aeefdd4f57e98d25cef9ee6288a915cc96207ddff402bc2fe81002138caf88bb834204209e39e6c9430ceda0294f1253f8be81917370a34eb28149200203aa2e1582961cbf9b22333ed51aa4512dddeb3cef2e4fdc3644dfa5145366618203a0ac51cf2e1a98ea343e9ca5fbead275516bc20e9bc421e967be939026677682164c2810712d79c82e75116a7e173af1208478e8ab6763c7ee58551b4f323f78200209943e959b9228e61e5def9b2ea18de1688565a2c49dd98f1c0a7d43e81a6800820c4ab26007fa076e1ecd22f104a860f10b22b1ce2ad1229eaa2cf204815f7246e204090f4ac3d42b2793d83aa79103e0d0488ae1297109a6f47bcb29d8c8b6d383d2058ceed8e9f61bca997b4fef220feba451f5401ff7186d1348aab81d7951b5e1d2014994a638981db8c53c2a364c744009050975bfc23fe7bcfc5c8c36b3df0bc2120e26863332f579b931156f0559025f7f96a4d72a9fd108fbfd5c29a71b13df72520e84680238c100c359efc6bfbb7e97d3ddd4d1d24ef3fe8a5208a714580e9aa552108c2aed76de2cc32cfd4d0fa02eea4be069e74da620ba09461e63b5127cf9d8e0020626acd1bbc9c821f30e5ad5b682272cc4b87a9e05323357a367e170ebdae283c20ff8a87793a1dd9996598d50333ace7856916b2add797fb4cd7cb7fdf24245a642165e43a1aa272317475fddbb370cb547766ee44842ab25d83cef370304213eb8b002121565e2ca6c09a263cfcd17fed20fb6aa06e1e798276a5f0ca8cc16dfc5c309600203e8034276a6f2379f8374e8fbc709a692b2dfdf271c0726c4f890282a40eec2920fa335f7d0543267026ecca4424d15cd07755568dc93c6557f850031098fa55572036cea7284ab1abcbe3c7a83ea779392fec2fd8c1524f95c5bb481c67e300600521444c7d18c458aa1e62e5efc656b27d6396c2fc9299befe67e3eef807711f948c0020829cf5a6319d4467027f9057dfa46e1de952a01233e068f7fd18cdadf9a3534820b622a3b592686cde240a056840080c5116582bf166f4655ace84b34145a78b0520fddc9480245d4ac36034ada99a182ada449373ea0fe16557c3c6d9d55b21942720e59aec0bc8b52c1ea0179ef1bece2bf3275164c8b14ef9528a1335dbe13def6620af95305f745778a84f8405956a82816f366957d31e74af5509ff0b7f7dee737221424e7838cbff036afc7e59ba67c6add560e242a1772ff069a31e5e320c08428e0020f4855f5217b4a20c250b75c69d494ec450321911f9d7841e489d43b3739eca792010f35a5fc740eccaa7fa007f33b028089dbb8bf539f560df19627d5bdba6fe3f213c35de64e6b2d64bdb6ba246a5c9b6a91047e35240db65c383a1be9210b7838c0050fd000184213201216f3c4d446f38e3733348efdc4a4dfd79febf41f03567e0ec2b5a8acb93639951dc506d8649ca6d1926a25b4f19549d2b3700cff07c100c47c456ae23df2e60e27f8822c427e2de12038e00293de92621bc4a27d104803e48d076e3ffcf8c31f549fe9f95c6b9233be396cbd3c557efdfca11fdd91397e52f35d6b3a2130fd46ae505dc66bc987d8e93689d41be09a1df024af243b843e18f298de218ab87e557c782bd20648dc4fd4a5e654f1e9fa59626663adb179f51fec2f5534f2f4929ceaccd34d6928ca3d42fcc5efa32490428abc3296147147eabffda85ec2be06be4512448aabe1829d0219902fe1e9cd2bef29a8f89ed8eb1966b89bffd00015e46cc825850532b9bd9d584441772b3963c554af1424ffa9ccc7d7de55dbef9456a3fbf7b4be985d185eb898e166ba646daa12cca359ea77bb7a59e45e8a6cf2708c16b5ccacb708839eef2ee4b5b6597ba3c5899c9f5214bdace3a521fe36cd39b77952d1bcc81f9e5edfae34f1cce40369b7ee492701351d34e231af8a3768cc158a796e900d0cac462f5204da5cde3abcf561ad60f91fc8951e4fb37166ce37261649f840aa51e6ff9be749d72f1f5b5ae3349f14d572860dcf36aa4655788b8cf5108dff7a4e231d2e2a3bec0a159a1e16c9bb38c4052439f2b6b1a62addf739772a1d51057c89751f7518a3b1cd7b37c4ff7d621a54bca5b3936b137c3fd0001b4a09bfe02e11668f8f6204cb9ffa9817ec412c820b6b394ce2cc3a12546ebc5052ec1c74876f3de8fa22e19158198cd04c1336a792ea47e63c2bf4e85dbe7460501606c97409a906dae4e7ad84517a7955793365ca4f49b5f6829efe61f52069fa19cb30ce0a74d415898e7e134ed8c4106cc9d6be32577e9a024b9dfd8129cb5efb1ef282802fe0066aa41e587ac9ee20d6416010e1b0772a44b6d6d1eb4ddb32b80b288952b26323bbec16614c227e4599cf721484443f56571c7b048e58fe48b1786c44e4979e105196eb9b5803795b8aa3d3e3a8f85e10abeb0f7b43b9a62dbd0905af620309ef74f281f39b241c4b4c109ca7e0ec55b13eef8ee0544c820b833fcfd65b4a897458cb9aa376c6bdccf1032e099598452130bd38d28096322fd0001b6c9b1d3d60a49cdf61f9031694f9f790a4e0118ae39cce789df472c13bba207d101410af89ba4b2a88cff5eb09e53593bfdc69a4e2706633a45c77a6d8f4baff22c7e5e6b32278dbfe97100271b1fd1e64463fa6a757c4cf4b19954d1d363494664750fcde0f0b6e35f9e0f08f4fb1e438669f78b10e800943840b15541a31c3090437364159681974adba314bcac37e6cb3ec97c3d8e0cd52241a4c162787c199a256599b97e488b8a75a46d2c3d8af1951ddac43b0d338e739deed1ba26a8616f04d124244f365573b602697aab0b427a8f26f1a22de77c563cda13fc03a030817a837c497732c6108eac62102641176f62d5b8e73d0e27e17586d6e2c2c3fd0001ca7e381637425fa77d95f92b8b491ea1398f53d763a6ce32a2b2ff972ab74bd377723dde5302c487fcf7eac93b089457761ca341f143b7c5aeb51b33cf103f2f4ce4c282bc5bb8c7290c2a9dcc82fce3dcd9669f660872f602de46e869fccdd99c85c06d1d7bc9af4e93502e4ffc385b9641df8f21a25a14b8e4cf99cca023da529f8689d86418c37dbe3d4116e08069f3d08b9c952f4e2164dada9e62908d57cc68b264df8fcbfe449c21cae576adb8169a97294a6bbd369a58654cd182c8403080b3a5a916c107d7271311a291841f3e35e065d48f6023ec4118a3a88d417507ae86b6c74e6ced02c768e0f879844e22862c357a9fe22574e30c179e2243e62192455f24a5c214eba9746d2a8f6a47625b53e5e62bcecade179907fccb08e4b200218a8694fa7fa94c9c174c8e1525bb14dd946981e66b1c02178cde818615e3b6910021fca3cbe539842d405545a71cdacd7a795b6f6fd1c1095497ee8ee215d5cb8eca002039f76b8f7067cd9cf8bde783c8ea6edee7638113ec6729093749c2cc0c15d13c2091ecd1eb4b171326a6a26d764e72e09160ff2112e577149bc7c24e6c0907324421cdebc7f780b73aef4358c6f7e5c9a78ee95dce81c4c1bbbf570cde3c43ff5ca00021c6955241d2f09d2e90e33601fc139d0603919a1011ea7ee89f0e5d53727c45a3002037be468901ac92d7c3481ad63d364a93e2daa023bbcfaf4f6fa40cc54974de19209178cde568ebc660a7698cdd7f83d5932364eb8ac144e62cdc045a00ccda5201fd0001a3347a2cd4b1f5337653554be5fea273a5bf0292c0fd60908c6e699802079912372435166baf2f71b35ec0e00a714e9f7c10096d0f39a65f66f220057f369ce7c8221dddd00e90604f9de897ebf9f06afd41814b57350ca2d7ff7e08f60c94dc1aa087b7975936832a7050ae4a14b47b1986e70d61223cdc37dfeabc5e4212a869d953450202c9c12db23f8819faad59252173ac11c54020fd7324b4d39673befd2b585437affe9398e7967772d408c6db860ec512f94d02b0cbe9c9d414bb4be4b08c3a420a549efaedaa6f0aed7c74044785b611b2d65abfa33374748adb690a54c8719131e9dda9d46afc9dd09c8abc498d6de657920642d3d47a55f17fd3fd000189cc7f3247c6ede0becf1ec89f5d92142d25a713e037454409ccd146dc18c58319550275149342c690f00b1e060db34bf6ea34473c661ff9f32a40214a4715bbcea59a9ad6ff976e43b325135ca16377be53be39255f9e45ab4b3652d629a30500f9e37671d56952b215628ef42c1e49046476e9a760041003742751b158aa85c4e95f70e267d2631491c60cf09a174816890a2dcc2f8a38f296389c3ea66a6d577301297c242a6b9ca7465d8282f2e72e7673f9e1f1c8f470949c970854134de397c08c06d5d21fc487b2b9529c901c052e838a087f15b802d081e869cbb311efed66ba31c17c73cce1e0fc32d58d35e54bc5524776ee3f45d7b8f81fdad4d621b95f023b2202f5d7c814aa89c27b5e0bf5cd64b444c711c2e1c4b805af4fd58300208214f56ae87b268fd16d6df77efaa715fccd1fb1e6298c1c4f7a4f18d39c2f75fd00013fddc975fb72a66d5a7f3ba6df9c1ff55c7abb5a8f08317c6b346875e850223d87d72d71d9b17c11bdbe6eca34b521fe98dc8d31bef6fcfacec74e5c4a6c5f00ac769363df4720e17c3b687a42f29e8edbc20184dc9274d8546368e3fd2408bb68dd224b73a1487c10f4e29b0d8b9823f7c73db26ed16baa4c5d75b162cb5a97caff3cc8957072902538e0e698fad2e90b471777c5e8d90e5cd313933c2f3b30e49b6cf7ae7dcc09ab2e5e464601f093973d99c0815c3ea587d1803b4ca1d9bcc2ac20cb818f95d9239fbbe62c3356ba41f7dc9d2232f6221447fc4858bbf11ee382bfc639d9101943d958a59ff9b81007508c0879c4bf16885bcd6eb2383898fd00018ef6741f57def1a55a42b565ebe0451f0208762da626acaf0cbaac6d9bcec14e22c15660c2a0c5a23b27c445ad968a7e6b2daab11463040eb50799858b7a5063965f947234beb0d42fe1c2dc7bdce7fcda3de1bae384b62e798995eba4836dbee41a8a4de269c026018a22687ad77985d049bda1d39b79528b320ad3d22d893c547b4dc4acb57fac4e0603468beafdf54408ddb7d4c5db0cf14162d0d735b3fad10888f0048035481e5713b846761838504a36c1f956b072b46f11c7c6fc86c766c36fc7dfd5068855335b96162d8b299e3cd21060fed730310d7748c3d16f228b0eea1f37f5eee5453e3a19ebe1e60e4f2834d3338bf1887969de57ef02a1bbfd0001b50e09d8212707f0035a46513208bdba63a5d1fd7a7ea88e181d7a3de81b8afb4ea4b0428eff67c04b27372d73896fd9df443aa9b2a42cdb8665271bb1c54833454504dbc80645bc02366dc998d334b2723bbf5ee1e139265c107db84774b4884f26b57818d39d05b47d5bdab25778965a0627a96ec303c743ba57c0d32d0337a6d3dbe982284109eb0a7976221816cf3fa7f2e3cf739f30be83f253564af7f411358d9c5340fa5578120c48b617c088d9a3d0811a575bb1e96b746e25312ff6d87b9705c04a10123c7606b4c5d6658244121310ce3f1d062250b57ee51e35e0d7b787d38206fd9d26c70ef94f337cc7108ae8ffddf5fa314e6c4e5e1538fbb0fd000192fa78c12fea8a45902ac86e2878e8327b5f4c2ad9bec7d6167bc5590df49cbddda4d4d652f6f087f2ddc7a2813aa5b33048adc5f900eb4acd983bc86ea7b89f630b647a9ef42ab1bb6484ebc20989603144912ffd80158aff7e7d40a1a76489afb21f5f711ecc3ba613868a7aac4f28f2cd5440dfd064c4874d4e398a4718e10de8f7055e452271f01b27544af7035aed32259796962035d7bc37ef843cdccfacf969a1659e354ec27efda8756b09c0bf855f4fbf7598273154b3f517303b6169bdbe4ede890a90d3b34c917311027afeaca7dcd27158b04886dfd81803594292cdb3dbb77ad3a8df97df63095fd28c275b956cc61faddf770608b898d74ca9fd000138b1c34ee04d01368d9cd8ec4d70b97633951ada508c031470cb4cfb15a62426276c7442e7679f926e93325e287ab9ed87446c144be02e559dd076c9c5e1d6a6a7de45438076b1bd7b9ff5a821070877c1d9626925c1f47838e56bb6982b54fb9e131741bab5ea38aabbe4003538c454980dc3be9a436283edc32a3de0321f114ad32cf34e860ca36f18d476980910e564af57da498cd16d08af8b4d4696a9962adeb298d7af4c8c4ad9cb911d80a2115e833f0856833232f92f94cc9c3a6a14270f4c30ada671bacc9aa35bcd3c945dacbb01f4556ebac4e52adf8790dd49fa799584d227ab95afd2da9e67e5642a90e54fc8b693a07384577d04cc40861cc9fd00012c7977632367de82810aecc22a1a802a1aeb6ac54a4a79d8e61606a6eb72effe1abd74a0a18ef83709355e77cef5666f16d94c0d710f5ae3973bb26d07c0a436aebca4d156d42952cc8cedca94162225b5b4780cf47a6426757758957dcf2f638ead2312e67e140ee8c380949c0885c68b396517ba122d90a0ed184564bbe67bf1d0115c77857fbb29945ea00d8e809c295295494dc8cca091d900837b60b31a79bacfae59fe638ee8a2208942e27f605c452be3a4a43cf21e8e0f78e7cc20ffd2c546a0bbcc404b415e4eae2c7b9c2f9121bc741a6f8c5c28c9df3e0169cd86809f5e235ce17fb625f913f94b75b360f9097b625a5305040c55a4057dc4a68efd0001d594cafd37d880576656265ebb737602d6f3b9d0feb3147d8c1f6e1514ca64e3ee046bea5e6892d8db9c094198fb3f697ae6fc880075bbd461e9aab7425ac3a7fad84071b170739e7815d0c76d2ae7fefb718ec132b32a842c58cde33605488043aad9c5df6e58401994a38dc50779e348cd18582ca74aaadcce6df39b221d4235a88000eb2e3efed6744abd0b68836d56ff5d9ec973be549d52a195195725caf3b8cb683da9b96868d35727bf4b74dcbee838d9c39a33d15609fde1c2b345974a93c030952f52da7ac20b8c26b340fd9c08f56b97ccce21004af28ebe4946a91682f9738085cdd9a53e160346cfcf396cfb59465d114c600d9571634aa3c880fd00016c714ccc93e995491adb6718e9ef69fb2cc36044d9e6b73130606b9c845dfdc56e9518a120237f58fb6f2546bec6f47ea6a11f3d898baa47682fe3f537542fe9f6d3679398064a48a3ef8abc92e89eea84c6d8ca956b3c40e9b82b2a496bb1230f8789fc7b0befc061468049d416aca3fb41d4272304a728322684f9ca6125b91dea97bbbba8c83045b5ce8b3110d429d65998b3aed570ecfedd176e98a91eb3410cd501ba52d176bd2c8d05e94acc8f352b3cbe12adefb82d34e174765ef1197f8a93fd4e03c98967ddd0332733e5aa0ce87f63aff78a2b44d10ac63c1549d9a9c6808b743e6f785173924d736bc0890f2e68c1df72b0fb1632bd4679e87690fd0001e736a64f64d58af8d43f0980d29ba57dccea56ff13040270c926a2703401b83859a94d6cbf4cd62109053c9e6ddd1cc61ac649ebb1243037adec455891642de8f43b57afe96cb63736e3e7d735bbeec8d1dc2c698f37f0bcd85bd84cc6b7e25eab500c03f1c62ec730c24208e2df2830d32842277d9e5c9416a91217cbdad60d6a77a7127b09e7463354266a1130d1f04de9041f0f83d41246766027700fa9a02d10d2b0fcb0bd534d22ed38278ce177bbc1c429c09030f105a67db70011d3eb24754bbb31f8a6a98bde215f635409e4cb8c3d769efdd7f1561976bd29876c8a11a130cbb8e3fdf15fd9ad0329852dffd794f499345c3fee09eee21997a5c8a021b993d7cea74a8935b42df2d55606a8841b1a4a8fc0321701d5de9bce1dd1bee100fd00016e510cf2b787c67956990655ca7ba97aaea25163317e7ebfbcf2681b29b0b821aedb8f9b2e309d37f660ac7169dc8234a7e7e4d01e79164eb75f28d284c52ea3d7edff718aa96db6b0b6781366ab985d202823130ca53c2a8e5186fc18862348952e967b1a3e3636517c3e3c48d8fe5e4ef1d5e230ab584964c888c61393ee3d6e34c50446b86e68ebfd048ac86065f9a9c4bbfc2474027b612dcafbbb0416f12d3d856a96557529d1144a852ade77f50f600ecf3c0e00296576eb49a0b211baaa815cea78e1b7a56a1c698ff58488378722f58fdb1ab8bb0063654a8de8344041fddd04be1b4b2b1944ce9d1ebda667caf9ca00e5c2de892a9063a449edd8c1fd00017140014f19e2474e1cc4b40a5a8033f3ddfd960ef33c7e35432deabd85a5b2c18a27b85477c9e6fbb2d8a5e6c686078009b1f7868768fbb5f569ae3429fd64e49cb3a62f32fada09059982a03a20494bd2fd2caa75a01b3ecdbc32acbbd648905d56cd2aefc714af1c24bd6e8f06b1ddbb85e9882ddf8f0e67c654402bfe2e0d9ba404bb3da2d58305184949ce513b3784c3234b39d25e0c6df741683750cb46d7856e67a0d45f4839b5305f9965808d41cbcfb2ad3ad18cdba6744eab0148dd0c1d5e687b6e76bdb9408766b57297be6fdbcf9d7e3e6918c194ef32bb776b5ab85f3a6a164baf866d93ecc3acaefbac43a9fa267bc623cc136ad712b00d788efd00016d3db1d97d5d429249f5cd6dc35f61d0b1b44f1e433cd5e01d28afa6cd6718fc1432b77e8eb6418b0a4b6fcc6707cd3d5c6661cf57b3b73b78feae7c89e25eff2ec1d465be91a18b2f3bc4311919f410cfafe8ae8a06b4b947f9b6fd8ac17453c2f558dfd67f71829be66250d40c3aad6e3ff52d1c0c455e7a4f646445405cf1ad45ba65392bb622a770ab0f5a57e73d32d98ee49d73ac7dde7dad9b9c8e1da9d1e6aef0f9cd120bc1d2cbc5ad819190b758be385f2b0dd736184382af8aa419ac7d734881ab4728ea927b6655b7d3faa4a1e14caf6d38cc706a940bff9c9021e4b6c3ce516d0257deda35d2ac4e0423f01ac849b19b919b773a58f34a06c6b1fd0001464885fd950d292d5aaa6155164216521d2113d795d9aff389771e8f39ff3d96afb5e2359fc52aa6d6cbc3921a7a21e6f3fab62e748337e2cd212456e2b2f52dcb352a75902ac6fdcce93dcf027138be788aad5e09490bbce637751cfd5bda9ec540d7daa92eed7b27ff1bf66585fbe3b39db3de9dd386ecd7671f2395522c1c9006908afd04fe68d88194cbbc3216377cfc27c4fce55c8e558ebc4943cbb477a1172aa8b344c08d6fb853e64ff0f986b7f3e7cc3b2c3d8b2abbc43e08eff15787bfd6a9a8f98b207d8e2530c0c37a37ffcec2fecbd726b4b845ff48a44c1ec7031e4e663ec0042663ff81b9a7fe7d599695737511a685148fce0c3eb01513bb20ffe083f86d11f3c552efeba225a93eb8ea756f45c2e49a4dd08467b79a14000220fd4b0ae4d4c5a165ab75af08922366e89721ba7ee52751e0b3e64017787b9445fd0001d0a8ddbc130bbf7bcd20bdbc8728e812a45cdbe602dcea5826f753b94e1220cf698c1212464a17ed846b29db82b1cf5373241d4117b07a8b7d279d4e8511d22c47be22291e9ab56454becf533b771e5e602542d07828952d5ef900e2548739d57cbb6254c667bc50a0f97c11b7f3dc1624111f9d32cb0d85ac4106b41cd6d82db7aea1135334220163c190744b6daefa456f69c331facf9083af360db6f2a2c80c423357c8fd1bc28fcfd42db69d733efa9ecdff9df079bde1b73a63ee74e5af5c75b67a4824a72466e17b501f6057a68efc19627d115f19fbcb48c0e0889857f0d9191db5875ad6d336adfbf7f09989b2aecfe868c2efc2cc64af46d07c44b9fd000151620fcd4091a3d3e78e8a1f1b5ac9ae8bdd3760320ed9bea2e1b237110d7747e9894704336b958fc92eb200f06507cb56a12f202a8b098bcec5b7b6941dccc18d2bd968538185dbfdbb6ea61eaee22aa8ad24d73df0adfcfafaec181fda3626479710bc19835a5aa7a3b9afd8166b89f5aee8d52589059eda61f19f6319335dfac7765a9a9e22cce0fb3236eeba6ce250ea0b7cfc4a021ca3c88859f556dc1137349a7ad5a628bc47267ad91ff86174a2fe74e3ab298ae8917d6a57a916f00b16bc0f3584b12b0d63141a20b1ed54c6551c6dfa5647783dd9acc68ed75044faf6745161c1ee4abcd9969ff9e01f14791de7d0c8e44e77a5b249e2da833ad3bbfd000119884821e74482b639ec1f8484eb6199a01d6e0a3606e25527d7a9fdbdabaad9f378a9aab04a153b1003d520d03f25a9e41c82504ad6de9fa6cda30a3ee1128c35f49d469a79b3c190ab0fab92d9977478c7ddabb6a66f291b58756e040892f44ebf6c8d0ba3cdf5d9335c8b05b0d34a8f4e832c54979274f5d4554af2d05aec3d51a3cbe03282c9c104f664fc39863c3a23396e762a5a8b5ba18b3c84f0f49f8b7cb6f627905a4fec65e5ab41e868561dba5cc8bcaa8c201d613eb678342aaba5e5d44f7ad7a58810129aaea2e6bb9850ef022e54a50b18e5fbbb76b93f050c31d279e66cd51a29c42591b18db05e88283e52070e6eeffd8fa447bce2f22eb921b2e3f53f38bb201511b9e1d5bbf22bf9e23be137543e34d81a0b194f373dbaa600fd00010ef2d9c59966c760260612842d16526b4352a5f05de655fd0bf50546be1d893d90f4b8264488467880be2c4d7f3c577e6b68335f1e0764fdb16d6fc44fc5bc2c1660798e6b31bdcafbd33a9edb44e48dc37306abe9ea761cd2077e977a6d5b92aff3cdc33f644f47d90fa9a4b172faf29e264c9d55d27cc4e7b7e5e891adb566d172207406ac400348c386e74716a518cfde36169e4cbd8d3093c8d85b99e54474fb7c7ebec5a3beb18b664b953f4d9037041c45738e87c53d55eb92fe862a78627a8f4f3f8f3ef01102b8df05c9c1d81da85e36bff90943e0dd93efc00edbec664fe16f03c8e79d3e105803c50a606f9812d3717921477e224efc9c38604e932116d048938c5e50af925d722ada7f78de5fb1020ef09d45e001364d0ac87ea4b800fd00015a2d2be1965546e9ffe759719faf9960648c9183812a7db83a24b0ea52bc26e3774ac36fcef82756ad386332d49551a147e87ca68fedb289b965a4088b3de6506378af1f858c1c995313e189684bbd3e484499dd7a26097ee6e89ff1d0b6d4c6c03917c53a95d9fd1b6f9a8e59e1687506a5499adea9c1bbf2f63b14207daebf64c8bd5742bec97f6364560cb3d687f8a1ce12a197e87df8b23eff607c97849672fcd5803b43bc8b6dc2090d7b99299248007a3207621c0b40e6fcecc3f68b2888f5abc842a44e6f019391449a7356f9e2637c77612b342fe61e76b307ffb780f529c6c84a6e6ad8d7553021070499e85542cd288a543bb0d318b7abca62c48721047951dd48307113449db6a3f997999f421c62b4524e5baebffef1ed21a6a1a800fd0001c4594030b3c64f25c491e6a5ab25b959438c11265e0fb2e5b2c58e3d59c86d642aff89f4e5137f08ee4871df4098a5748795d596baeadd2db0b006e02822ae2c5f6a93c5281b82a7d60f8c390a264d293f1769d55a5b1f78f0f65f7694477ca79ec16677993d5b6606298171df85a7a8c9bc74a2438618d2aeb384de706b797758dd418b2b5d59dff089f7d4f7f21a8390a3a707c08ea4a238682670a0d80297ba68febea9c4c5afccc241d9f95aa77366880ccf891174b33d95f462839d2820451611e8453d981a703595e8fc73df0a4963c1baab591b7f3dbbbf62b50dc7bbfeb05ec2ecdb1a63d06ec20c4311451b4129a08b94049263e180411ce7b8b7b120a96a6184af4c9ac706b2fb01d6389d94cc4e3930925a5b2bdac9e96dbfa42a78fd0001cb6e8b1c0da834c4fc0d797fc3521eda3ddacade8a4ecfeb518a2e2e8a234d2f6901a7eb4d117404328c70a5c24f36236e88eb1dd19a7e9cf4ec7582f472da053e283dc193d70bc71867d2a348521a860fccebe0b45958f1b5919cb833d79802640b7665b7ec45135b24108eac8a882121b6e6734dcd327b506a434cea9298e6067c36457858e3c18d88a320ce33cc3861f5329bc1f9a8f4af57caf2c134051b12dbb58e309d7bfe9028c3b9b4179759095fa531cf20bdf18134c517ceddfb38b4d94849c3d7eae9b46c353bc43d2f8345e345f818e328875c1bdcd754b706258779c7c30592d0a4c4b5cb557eaad484d1cef2bd4d98d12e70b8fda33f3a5e8320486c47e0fc0d0e679b413041e800ed28ca862ceec23c1a937954131b960fdb3320a3e4a1a7ded3a425d38b144b090d9fb0aeb9ba631622041a702626583d41142520f6fded7e7b2408e70ac4ab0b4d23bc2571cfb9048b0bc738dfd0d6507549a451fd0001656bbe84db36e12bf3c78c07ba3f561d2c2687aaeeef2e2da17cb00f060e025a993c12551c12bd4d75c4c116d18951515f42c5628b0858d85b8afe5deb0aa14a2e33d147eb62b9a52bd4769b6a97382d6c39e5f70e727ee0c9a4684fcf9a03e7232394b4ecc6a2d32e27fe2b436b1081bcb4f3c8bc844e9cb1cf9b828bfc155a2467d506be2f89c9a36bfe2d19125fb21666ed4625cf881ffba75e67d209fde2742d5a46634019cf1f96c1e649a9dd58edbc374b9d6220bccf20055104e8ae8917537fd07f69e12e0b8200af3d924997ee33a7d1e9eb3ebba741f0edd1b59f05e1f279d0c4f7106276968fac8055088bd5f57840438a2776bb21693d9587d188fd00011ae402863053cdeb79711a4c4e7472a1559d86f89bc4daaa6865eced1126197f3c43d78ba16fb2d6508632ab4712b13c3f45d674064a51867bcba96a71b7683fa69ad337fd0c50e7927b913f025fcc47adddf1376a053280cd0fc45bbf29767b4555bbc4054a824c11dc93c3648b3e1c2ca42a5dc5f536eca084370ef65c5a6229bcac295c3fa578fc22eab793205cdc8d37fe3cbf7dc6e1a7c53ff3c7e4d226f0a0d4667bad282c625695d4a1ad88931ca894d4931b19b09c56d2f72e72f62600c97348ed8814f0841baea716d1e0d90f26f8c3932a1e66dd1e8c8e039d3e891ab7be0a887c16ea9fbf3dd7f2c7200587ef56cd0e75e8e828aedcef6198e6d6fd0001a265b258bad27b42d12a12e43a25bf566f2b77f6f0924e8e0c32f294768fd6d9f92e5a15dcc94a2067c71a1f9740700ee0e7f626d35fad2c441b176a077fe681515cb0c8613b0b43c708895c9ca5a41745ca87cc5e8d02e272a484be75cd9afc7478b98bd4c030c3dd4885c5214efbe70cf9a1f8a2616e2eadc150f979e1ca8c389b6fa288889464886bbdaee7539c6bba71ad0927e455d45db2c7371a5b15ccd8c7e7e91592e9bd057d85a18a9a9d1165a84329a6c7beab031f2819cf36a26aeaa4cbef4e7871c472b9b363a1a5d597005cb98e6828a7b6b7ae81cbd036f8d8afbc6289efacedbe51c10f27462c81525b18d119ab4527d9c6db52cde19cfdddfd000143a024d1136d92c3d0de09ce4a3837fff20ce4c4307fca87b1a09acdba6a1df122cc69dca4ec3ca89118ade730ec8959d0dd84db0eff5ab2ff71793af68a2d6bf3a301edce9cf1088046b3177c18d90f6318e2bea3a071469873d1a320d5036a6ea1375ce17113721f01852e1c436745536bba80365d2ee1060a73098f99983d18511059ecac21b84131d845bddfc589a1a4c195ee1d89ba9845c09d681a87c3fe2322cdf571b4a31756d2de38276b97ecef325f4ada73b747d78899c3f84aeec26fc9732ef5843f8b2d7af0fee0f04e01f85731eb9fde3f0a69c4c0aad09f51db5cd03db3627cce5d2a1dfe9910817efc2e53ebf9f79afaf09521a3f14980cd20d6c0a0d48152ef8efbf7a58b809f5c28186addc9690ba0857cbf2c96e395e92afd0001781c2002615bfb1b7d35b2e208a1df0bd8f95f2d639422c213683828227885660bb231058546848fd01763a1ae99e0a7a1040a0f398d8171b45ffc4a58a4439a5addbad802fe79c71a4a27fea8ed229c51d6cb26c3e21127aeccd2f0e31ff1c7d38987c95b917d4c86439a7a0d54b3985ccc3072727c654bea4d473676a45f13ac693de273581cd6f864fba7ff00f3e61cabfb689689c49849419ca1cf489cf9db2138f65c1445b74a0e0cc83e8368e6e79149e699b6e64c77c70ac5156bfa98794cd0c561732fc7e3623673e1f0ebc09de026d9745c4986d498762be6799cf99143fca5d69d04283b504c8d8e325c8811853d467b72e204417c7e35064ce7bcfd0001e42f7a528e3e1de18bdde6e2d9888886aea8ab8eb149299c32c68f6c93a19889efc05e641037687a091933cc83bfbdfac77d48a2828bac6b0625260d4b9da29b0d215d403c6d3aaaf33addeeac4d46875cbc4e3edc1a865d6fe0b6a588631188897cfd131672a8c5d098c0ff8ec5b17bcaecac1d78f83b5296408c994c1e81f93021da25ca403ab6694fb3e82ed260e88067e3edcd8cd0e70ce4c79b49962096eeb1eeb2b17be69033a338ebfadaaa1293f7fe17fb7ddce051d9eb82c84d639bab4a415353ae82dfed2996a26d184189ff1f25d3196c67f34ae50a39bdc2f711ed6468b364d1f0ea8eb29f486dc6c2f6dc59a24acf4772bd542557e9ef4b5b93fd00015ea90490404b84482136f214f4497c09a0ca87dcebecc03ac3152ae1c3e87b945f721137c7a309e1036d0e5d94c6cce38c36b1645a62c7160ca45abcfb5c165eb696154ae38684002a150ab45f1b8f1bb69b28757e2503967a1432090b6c90ce7e3671860e40e95200f8a1ac1ad927b49bdc0a66472eac7123c383ba28578b149f121ad8b1ead1e1908858a640ebebd2e1b8a4f787e5f41d573168493115448edb0de580a8c281b783afe2b62ac6ea243d021187367df9fd28f97e2ca7c8856d89c64a4c3c5e2147aea8120b4bc8b2d0b8ec5b7edeed35d24a800760e82ab19cb7363c7b2fcde6200b8e63e2b698489e9dc0bc4c8f1ba9ff68b59a7277038eb920fe94cb66b60142227c026662ddbc3dc29b373c5c805c365b245c2f69152f1e2b2007e3634d06ba18b973add33bf3e23a7175729a9442fc4213adcb0e5a52e2cc272096af7547b4220ef6335cf5fe47c75896ef2d27e58acb6d7b444b3645ad1a9370fd0001f3642f6dab0eedbdf04e554106719fde491a9bfe00e8228f250e0035f5a95782bef5680a15e6148467d4c7db9e22d9a4bc766ba884940645845b25ace95d405744929adc2c63e41e4c807e33a0d514919c09e855c9d77690be00720d83dcf2b2276e157d39b7acb3ae262e65a8a09ff49478fcd67765dd03b545d7e83bf194ee6b0c5f83c41f7c470e0a1c3f1014a7afc2b7149c01b3f1181eeee4ce8a9be47f0f7f897a05683629d6164fb882e1b67765e7560e7f5c6be76ad9902a755c6af0c455156b93f14e618f533feb9d351bb956da352b7a9d63cc5082d6f9768d80f1bb703c84be2bd75b848f06925f47e226c424c0ae8ad4293b0e9c330cdb16bbddfd000158cc2778c31d0436228349fd19e0428de5916401bbed1f3668014cc51cc6b42381c32e5a7d5ec4d194037b0544284c52e151b652e2733b17065b2a98fc1f5ff30a8acf5dc7522b64eb94a8a71526e2aff0acba058b541fa412bb5344eaae4945ace66b4f9251e842e4b52feae5ad89ae1ce3617debbb551c09c87c6e6e69b7942f1178db84dd758fcb3f63d658e3f48c1a47b725cd2e003a59b8e318950a45fd908c6c4d53d706dfcc2041c046324e3925c9ca032f62cc825f0c11c9fff3fed99f616837244c7d71b3a8d79a8d5bba3284280b59ca1d0ed044801b7d4d6148f014c8c04e8859d9c3d074a7ad77b86ea0ae39fde03fb33eda53fe5c1728cf2daafd0001991691f21883d17fbf12b24e86f0f639967240dcf120c01713ad612ce32a7b8a9c911248414d8a2b836e82c827270f60f154d5de0efb7aec5b3db3f8b9cedbcda84aedea5fd0988ed0069871fda9db5ec2a1ba0f04592048f0040599023ac01e758886daa3fdd9190bb1c8ea29898cdc711e8e8e6c4497ae95ee2e5a6730bd8388d8fa812b21c9b97af84a7cc9f790139b0005dc86efe16436e63982fe8d8ca6bc5bea86b2297a0726dab7704fdcc8a3f6c273eb0f8aa008ad1e6c4a985d7cefe05d3b24cfd195d2fae5392a48be5bd50386244fa9002f95ce18efe97be356a3c5b3990172f812987d0fa10d7fdf9afcd20e165697e3e8f1fa564d1f03010f8f20d53cbea6789dd88800af410af54c3c346483fa085c6e02c088092372ce828e2afd0001f903226d53becf91fe3ee0e556a7ddd652e179dc3c2de5d7af38f380c9916791a37e149ec620429b47e5e0c016b898ee1dc45db857c93a718daeab3167c3c336b22692da51bbf7ef1bc42cba25af0b1fa89df2adcf41535803ad6e80ff1e1f57c80514f1d091040aec55e87c4810ea31ba22e4f93a101d71e324cfb2d84e381f1a59a601ea97907013e119a24a468b27558b68de170690e71bc3c2d7361ca7f77d116b642516d9cb128a70d6bfba83b2c22420059e8c22c9f794f2e144a3a065c942d3276253013efaa88a80e3d7f3c32dc249347a6df656df62d4f9482cc8470bd1bebda925d47c5cd9a54ce81d7bda23c2d0434a98a1f73911c6172facb08a21cb1b1935a2667416f5cb810c787fac55fe8e14146721452870f27b1d5a14fcce0021600ca51450ca3b29e9ff6b388f4df5286db585d027f68fbbd1d4a95fd756318700fd0001b941fa0b95cbce10d49d29c80ac6c45bb624fbcef94b9bcc75d3593a5e11ed85488b6332f9d059e0123d5df6486dd2f57a9239d137d46f3b9c0120d391d1f06cd48c13a0b847020d0832b15162461811662eadcaa2757e2b6b2240d478e7411c7e807e09ef824ecfb3681b49aa72a3319dd310d8efd930ffa7751a222e73f198032f2dfa00e978c9542dc476ca44b161fe470a5f63759de5086a18fc92aa375c608841c6ea41ebc6fb86dea22a8987f7d9abac948d5e67a173eee3b9b90c323c4f2624f53fcaaadd79427a36f560ce6cbd99872d8119acd2173935b0331217e33ceb4a0ef314e45c1a90ad06a46948a38a00feac8f58d8780e6d15ff6e013dbe214cc25d39b2def7e68e2d5c7afd69c5d629265f750b683dd660040dd3220a2ad600209901a1f845f602ecd4f341e7b68c6787561b4f18d7819c8b319dba6836a42517216578b022ea0911e03a47f0814046cea045fa63bb506730b0b491614417df91f900fd0001ab0bc038363d54f8e9e8ebed6a498b0f989c8ee56c81720bd66f71d0d97477d0db56d4481cc0e57c2316f4bd3a6843f86e5b28152436ba23f377dac267c7bb6501666efefb705be623d0491dd42a1a5397f45b6be6ceb1e0499842914f56296a34f304320f5b623ae7e16379d89394b7057d1b92de4c913265ba231d81dbf9e4b586704803baf1cf0fd474a721bd65206df02888dd77df03c831f8433f3b2c7cf7e1211c7c85a975d129f33734fcf77c09aaf68b681da7c506e8c89ac5394589d185117b722b757e307ccf31e9ddc1b9131633bd684ad458fdef09d346566eb4df920801ec4ac019081feda518cafe1ff9f9197b1473ddb18d3349652db870e0fd00014168b6756041ac27a58e4160cdc78c2cca30e144cfac7c58d40b5521c76ae171399e4e22bd3eb5a59d0a44666cbd8d38c4983f8e2bac6fa5ee84c86adbf9679bf8631dec545667463df5fbfdd5ff2d0f6f9021a4a03510e291253bd133520d5366e3c272bf8ec41a14b15aea97c420ff263bb52dacb3c3962359312e5a4690483434ba5f592057dc449727f03768f3756c24c85814e1204d2d90cad6e656d39e7dd7b9c4bfcd103dac62415b0d64901b01278602553c149f6d64342f16757b5ca1033494395404fe1ac7ae33d796be12b0d4f986de23186ecdbdc8477a742fdc973b34b312f5984b74faa42f5f62dd4938f76f5d7ec141249902825d81ab2d88fd000193a2f7ea915b7e506459b8484c43d305deca93a84b3c313a6cea7286d485395158806c3d7f3155f3ddadbfc913cbd7673193a951b356ae208319ecf406172cfbbad940f9fb6accde0c73ea5a19037d5d8644b4de22db968851a303717f473c491465712da34aba4e5dbc31361163ae1492bb3435779569598639943640b6e14233d8888aefa72580e22091b6848592d5b2273cd2df009d614da03d0c0803b4320800094dcbaaa337fd210820e07a8deabbd595d2b59b3bee5c7a27e585a1cef44e532de83f08f3df43d68f5934ca69776b8ee8ddc42969970d64145c093d4e989cf32ba71f750cd03b5c818c33462d05c6477ec1f0ec81c48e4b119d05d31cd5fd00010b1cbcbad09df7edcebe79a913033895c66686f078402893059e4a986fb76d28e89324f5b04ead2c2aa0a05786faa1e8f66fbaf66b7ca94bf52123e6afe4dab54bc33e5860e8766031dd256edf40b294f55062e613cafac04f77e284f5097e88cd7aaf46fd28f4fc24ef1ec1aa0bebbb3da5b504e4dca8dfc13ed99e4371dea6f3bc0f78cdf6ff47548e5a426c92095da045e5ccf45f446116e2c9567ed01d62c1fd3a78c62aa359118f77705174db6a4faacaa0b6d49b4da38e42b20e9cd2ed7979a0f6da28964e0b3fb755046f0a5477dda7465e00d13c8751c36255b7f922dbfe108a14e359257ad607dd7e1c9ccf330779135714b01746a25ddd11e565c821713c8702e50956f6f6ed9c283cfdd08938c8ccf08ada04309e47fe9ddf5cf5dc00fd0001f5e2772a310c56c1343104f8f618a6408dbdd5e421e31856f271fb33329d5055c4c73e06870feb8f7b298cb9b403dd6c4e87dd2137ac84df583c58ffd627b0c52996bea39da5959ccce7968455d3b4ac512c3d552cf63f2da74d6de8e8f037eca47a715d0d9398502b287e5f1034e536a84839bac21ae9958da81fe54c67165fc650f19679c9a628b74fb40c65257dc5b50f690263f9c1eaf41c3365c0e23f91aa7ade32a352c8b88b40b2d7acabbd085185f7737be6f5a7c342e2c908e8a4f997ec949e4bfd6ca7b3da894fd4581c81141cbfc01afc3eb86bc91c304c18c1dccde49d8b61e41d922fcb02162e444dd4931a076bb072ee3a6d5cebbe8683e992fd0001197c654987867c3fa8c71ce3718a70756063be0179e55bf302daafbbdfdd5669ec38e140e3204b260b1d54373f84657101147e672120d735c179fe2dc5fdf7dbb8aadee63b867e31d9d1242e45bd23ed510dc7a6d44ea7806159895a2293c0898bfa6d0018d9864f584a59003a8d4e3a38eae3ec7c9e35d1e5985acd544547ccc1e31024f05bfd5dc07ccbf535b2e0a0a703548e355eb9d1acd8a59684ba0fb674075011c7be7cc899c30ea6c1df0dc34c9366f47743aa12991192cc836db7fd5a3b0306d6905a82e13b729038c70fd3bea01def02b93cd2e71439a9fdd5fb93da6a72436ce9f5b431b7e18c76d8173964d5a02e24195e761d888a6a2532d785fd000177fcd4e94194a3c34efea9f8f04faccbdeae9f0eb8662b0195db08e2b41a08fed52ab7060465ecebdb2eeed7a768c4f65ed7b5193151a56c752cdf97cd4c6be8daf95c16c6703ec95b6cb541506c715983be6384031685d48190e611ddbded6fa377affb759a1f289523e56761857afb3c33b68f743c59a6bf2098a2f5bf3c1c8a3d41224dd12ef247874ff5ab6257cc3e29a65a1c16f3ecc26108142d1899a340be3db55556258a348f51ef61e8e73fa9db743f08df3d31919f2d9484603f0281422c2cfbd66959dc138c2176d6e242a5a04d5b1cbd8c39ed051486869f8b0f3ecdaf42498be6016dc9d49ae52220612a32f50259fb9a8be0cab88deafd56b12024a02c37de6161be5eee8cf63c3b0d16d0bdca07ad59fa0532678b864c13bc20c87f061046d5218e768801c69818dadd24e62aa4625d5a2af09b08f9eba8efb5e29fc2977184990fcd81a0a3f896691051da425d7b99292d8ad7a3bbae5ed9503c5b761907097dd6457a5444b9c85ccc829d5901d46cd917ead52a3316f96588a7b508501be9f69c73d97e0db9d615f20e50712f377663f6c47f42d005db72b225048abeb8d6e3a60afbe21cd13d4539b7d39806952d745a7c49a552175135d840a3f9b9e9ef6ba3c46e9f4bd42f9bbde0d5d5abda8cf06538d3492aa856d8708285c0aa99567adaf6e0d4b28feec0a55e49879ac8ee966e4520e5eeae3e6b4473cb710b4213973850ea6ff0331b04ccffab2c2c3f55ff28b8bdf644e4c19982916d01e28d745302b079066c61fc0e8e1c90931f122893f7f5eb86e9111f98cf36e719129668dcc4718c52d0c4c6a1a941b939e5e744d61aa9fb1aed6eca5fe062cd109b15abfc5851ff77c026e9ba7023b298d40acb1ae9884671ff1776aedabd859f2a481eb18b963afae2e2ec41e3f3671e9dfaeab234d6aaf97249f4702e706437d8e36f517b3b227bae68b79064a3fbe999bc2d75912d970826908dc17b815ec0f030e20b24f17c527557e36695abd05f67c60475a0c74aff42b5e7fd13efc3b1c3bf5ae6e3251731ffaad110c4210c9d6d78d69d6f68cc31ca99fe783a12fca506af01e28234f01e1b30dcdccdfbe696bd5703ddbf0554c8c4863edb0f252e2a9daa54d4900d8fd6aef61b8a8f1e165eea79a3f20663b08722762f2c548e0cd10cf0d2d8e8b9239b638d02c49749d55b721a6c81d2554b51b49191518150664b7e4dbde3ae02b36ab3ec9250290c8cee6b7371e0d3c7ad64d267cf463c4c82cc5a325aa72461345c4f45c8753de23bc148527b0e982511bed9e4bcd4cc37e1e3b5089373fcbfbef9a111c96cc79e9f37d619c0d68598541f2a37ea0fd614ca310c915c0d412d2eeee8f7d71e4a250bcc60d68ab3f296a5f75d75d627e5913aedf3646f733701218878049c420ce0089ded11b087f16111e397d0f2e354f1df0a858aaf07eeb8e80002210305cf1786fa950ae9b553390d6d62e2b285ebaeb978822439e0922403f9cc7dbc473045022100b807fa7bc196a7b2d7a3000e5e1870e2ff488bfd6e2850aeaefb3c606f28379e022009c3cec446550e5cb04483404a677c4b8406d85c62cb4d714e5ca3a50aa02f2600e80300000100e87648170000001976a914dbe6d470fa9fe4d037043533eff4f80aeef0c8d288ac0000000018b6dcbedb05200028a2ac4f32bbab010a001220000000000000000000000000000000000000000000000000000000000000000018ffffffff0f228aab01c2028655e8030000dc8ce67bfe1851477371a9ac40b6ae0cb8571f6e2d5285855288f6079f1ce7239ee8c85f465b1820058b79554f41af297e9caf95ce0084b7c35dea0b95e15a2fb9f8e62c5427c1c36120cbc1fc11ff344909079335209c6b84b45a9211cac960f64e9432ba5eb6e4ecb2068223dfe3d85b345da17bf374f9140c9577c148bcc431c9ec3c7d13bd2363dba821381ed9fa0614416261e88330b3e74c40e6561310eab3f26f092e72f3cab761f373d02680dfb52937bd9515be242f6573754f8f665523cce3bd606c8ad190954f8181577fd0efe7cc64b711d03774958df4a5211e44870302056557777951d7ff8c002161a6a59e979f05469cb31770bd484be6525625359979220eb7e9912e835065fb00fd000216aefbae3525166510814d1636b76b0d48ea3cd54a3a17b136a84340989d75f74ff952966830e4c0d59daa006d5a7190978270ee9475a0778afaf002cdce7efdbfad630f72838b5c4a3b538ba61b94bbd9e353437a50725af5f16fbcbf36bb34e7da54e5c24dfc90b545f95c973877bfadc2703ee10585a1fc1c97d7377bf41c9cbcd5a313849a3c826e7c1301083694e6dc05f46899a901ab4a8d7f6b3600df280157fbef6eca4c28fc610957a42a9acf7c4d7f9846ab6b9b04fa6abb5fefc168d45f10078b97d4d6a39638588a1c19e1bfc472657861a902c2d52cd32fb0463746f649ae88bc0602dbf35816fbccf91dc249be809160cbc7f8b6702d6cc5b81fdebd283231f40758afc899f6fecedc51dc4e5d09cb8961092220541f75ddad45680ea92b4ee78c29f58c197a68420bbb25b450c72d02d7249f7facf9927378620eb36fbf9b4ccbcb55627eb9cf905b4a4c65fcb77a537f642f10901b6e94afa37e4afb0d6d91194454a9c2dd8ef8fe4316f8594c7822a7d58cab09657cf501da5be5a44f947bb957b71e4291a7fc60cd5cef9f0676f7c89123c7ff1ae2e6dc001b6f19785534e207fed2bade8597541b13714284f67d6986bc616ef1b0adbe415242fee85acbf482a6a48b3f142ef7ddb5dd1c97a4b0c53c6ac7aceb8c042d9c9ada1bc986b8c276d07fbf8512a3dae6a357fc02b167eb85000040e8693e45fd000200e23abd27b258f9827ed58545a507bd465e255e1156610da314bf7df68b6b55129df84c7b19e362751ebb9beba10790c9c26c5ddc7f087258d81b006c0d2e92be0178bf5edf6e78e89f73cd97746afbb2551dbc97eafe32ae62e7f9ebcd14ad69faf74d2011d16f2c50775f4f499c87c3c50d9d5d486394c2a7f462675d2a4885493332e0610a78fc0c8b08eda42e4bfe93b8c7f80a911a7992a1deb7cca2e40933e1559815688d4e5ae5e58d706bc513e5108449a8393928b5b77ae73cb03fe212c6375b6c5e61fce9db16360a147e7f7fcd49e05a99711d4d5799be77f7e39d9d1397388d6680d4931b48798ce013256a586781ba80168bae63bed4d150b64a73f7d0ab0c9ebb42f5d4db40eeee303783249af4bbf334c660f8c084ed9a2e5fff8be230940a4a08b59418676ef005192365e4e67757288791ce4992b903a31537596cf6dad0be2af2418a6b9cc2c33e99d874168f6a29df189a869b16eb5d24400ab30e4eca9274114d646aaaa8bad45832b6c0ded2bfa698939a8af9d0af380d2afc58966afe0f45483ecad0f114b904cc2fafcf470dd4fb8f193795e8afc3243b4c946d5eac82babac6feaf4ff10bf53acdf8347fb9fb7a5ac4efcf160f0a7ef3576f439404a3078ea092f46f408a955c965344023d847fad7374cf145cadbc8348eeb2c5aa999ebeeb8a5548bb14e0092b184caee354020c19d66cb213fd0002729bdb186c4c494e17fd6effe29cbe7fbc539caca738d54f9ffc6e52a35a27da134192e2f7f4ee2a86af281b78670e662677e97a1ef008f10f42349fa83bf7841d88b1a457d38164a383ae9c6b974137d58216f22d135d30b9d6e7a74952a7e905f385141f4df088415d704cc3b03b2cd600cb5507f8ea1b53fc0e73031a3946c0d6269f020c9c26a3be3bfdcd37f8d3b9fd42538ebd72029fa0bd8eb57a4fe6769e1b43b5d5d7be311e12e52ee9dad67aa988dedc80ad616d7540381993d9de91a7fac6d08e4414254b9d1d72940fec032833a6b1a5605f4b62c47a86d70dbec5ec913d0a613d438cad385fedf24566bf79edd17238e55421520b95772224623f145100e663b2ba20161784f688afcf07a900ade1d48060d21be9ba9297697891c2584cb99a44868efbdf65178592ecfbadc92f4883662d6b21b7f266eb21815c7401b8e7da061e3258dd685f8cf65f2c2e407c913f85d053b05f6f92ed1299186632ddcf175ccbbc933044bdac5e10916917dea1146f77a8ba4b4fc8ce260b5deed395ecae9b81baa6b385fecca5d2982041c131ce02a1dec517ad2d459434aa3a514e7a4c6c1362401b1ab62b4c89bd7705d5072e0be5250c60c2fdd946bc73050d3b8bcfaa73165eee3660063f279e824d1e15f87307a40bc9e1ccc0f7d7087ba84fe9275742455241b61d3687d23eb9d7a9cc18072ed8be1492db46a454464090750eb0a393499a73d23f4c552ec6ae425a77f97d4285a7f287066b2198bfaa99073da6f4009755e59838e48cd5fe692962b87da3ea7e14b34b352fb2a4673eaaaa594a094610bd0cc566acafc21891b7b0c2470bbb338f579231e01c064f275c5ac9c2748cc50e7f2e36f2768d2a59c22d14b6b9a431f7772e716731c55ccbf086187fb15bd07a5af3040d468d4088ba9e6383f1df6dead9384758f2da81ee96370d9055ce5a0db56bbdccb57ab490f42a01e083b61c5157b3c00e2011dda865c7294cdfd2be5c03a1a36a4deda9cf03b500fd0101b3c97046a717a2f38fe2265185f4411cc68cce6cf9885a7a8fe6292eb9e3eca69fb6249774a8c82b888d22d5fcdd549846c3ebcf054c6bf07aa4d6b1c0d4d9bd8e16501f65373ceda249e9c760848fc86ea92fae7142d211c8bb4a287c91eb08cef7678ef1f445f76f81d464eb1d29ce5c6d6286d73d49cd0ef03b65376eb146a3ff69a487ec90b53c11cce613a586f22cd56fd34df6f7ad64fa3c68a6ae5c9dad99d4a3d2b0974ea1f627be4f0153c7b5fe472d0c562556c4d8d1c7c592bea45bb7886b74f8639f9487b2f6aebf4848b5718f2ce65b8f5a4efcf140e2857bc9c503f0058f9b6af16e75f2d530fbba8979f81569e6cc0bc04a54e30de4e03a9300fd00019b881d73a78b86771d41c0c2a9ebdb3899727f33d2d4d81dd27b6a00b4c7db265999b8442800750abde7cd97be0c691ed06b5d40da115546d90d4803e82c61d43eb5e2bc684adcf180be47660870921fdedb2ce43f33564541fc0debe175c6c49bdfb51378902dc709594b9b7d34d0af70b67c3c608aeb5185e78c1c39cc3080b71a36115f623a07a4e1a3e1f3e17b6f9f695f1a1acd9dd1319d0a0d67b337e64f720e5168c09196244bc71b083f302e042be19b6aa1f8ad61755f4883c3a1ae615252b884ca3cca5e18a023ad6725f08f9e0ffd60e7a73ccd29afc910d60dc99c06f5953c3e398ef615fca45f6a83f8a0be653d31a7e1a1666cc7334d9cad51fd00016f7efa38c8f9f478a6ea1217d8be9b7f0b01c5d1fa483c26e403eb3a4875aeebbd0e7eb8aab8472a5bd80e8be38df13526042952f813b71f8aeb4a2281bfe5e5d9ba70f7e4c9f477706da922899f505dd172e260ce5f008b59c0590d498ac50810e9a38d35f1a2ea4e9ce8f77e46d0b5604dbf94629cfb8b65453a0295eeca9d992365309b7956481a8c5080510a09183bd3358fb26933e15c83fe2ca6d186e631d72889a09464f5dafdc8a93dd100329071e52beb522bef1af0fbae0516ad3b02011e19a2b2924791b3f22679b78039c8356c0e6676e2451487f056d0cff064f55a992afba08f59af7606a809394772ff85c4c40673dd54eb30020c6a5cde3cfd0001ecbc47845e4a1f3c05c4e9d0a47e5e8996326f48d7ee1bd0e432c5f33fecf8a94feaf03f2da65525bbcb119c7928456c28f31c183a21af1acfcd9615669cf47a077f861f694bfc1831f1a71ab66540906c62b85274f63abcc53a37e3fccdc8563ca2153818b6b796473848d765d1c81d4e6f78ef8fce804184e04664c5c6af7c3abe4d92aac3f6ea99b84a3e53a7bf7679c26dc96804ac9e1443b054e3c55fe89315106a14646d06b84122861ac0ac27fe64fbee3c9807b0264a90eb9602187f4df2cc0c62b025ba70bbb30a7e43d533f32546d2e6f97537bc2d623a7f545676a37cb511614037d77fe21a35ce4a2275e7ea1d85b7bc19e477c61033f5effe7fa5d8ff48e2873845157b7a29718e6692704a9d864301fa254798555172c6277cce4ed5aebdff9c27a1bb37071677c16da15d6c1547c5b708e1988eba68befff1f1743342ad7cb89ec8731b567198953d965ef6e395d38b410a326ef3e262937c763179f22a076d17f848dc4e36be38d838d5788f121d771f23209a0d50fb3d016c5cbc47cec31069f7d22bd78691662d8b609932b9533bf828e213e5ed4e61f2b80f05acbc00fda0017bdc59c8a6a23d261cdf1dd10e91523503c3375a803755c29b6a84bac626708b7db937ed39f4053b5c6376b3988fc9388cc64dc07466c67704a32b703d5dd86d8fb8597cc2ff7ca190044c66638028855f29fdf2a0943c64125e3ad2487de6928bc34088957eb32d843613e6b588a91cbfd8c37c24ba655739cefdb203bc3061ff93498160c7e949823b0947c68b6c51dcdf038c538f50413266680ee23817ed9cb840e0094f6fd2277012aa2c6f82b086242e6332a4e00bb9c12c153dfea9340e681e63d72551f8830b2bb2587e5937685252928894bf90bfa174f62bf7ccd43415a094bb4142fcfe639c62f6eda0e4d7ee033f49f51eaa6a35b0f7f400992d8367a275e9d018a547430083c41a35ef543bb92e159efad39c5b84d127bc68dd581c308afd5f81654ffcdb3317dd6e21d5251916214e873c83b6ac197dbac6c1b93d79b9dd5da090be204b9765fdcf662c9296295610e42a0570a1503dd67c44e17936946f3a6ce61f82b13cf00a0b47d06f2cd28651b6af32ffc58304593d5ce81159ccc952ed980f93182fb468ebccadae4dab4565d64a5bcb3aec7a09d681fd24016a4905a3a143e4d61b16898fddbb0f9d7602ce865716b62ae4f9a37e2f6ab89de930e066db6f4a8667ccc4e79e7ac760642c33e5f24266540c9b4fbadda4c0aa1b6a74d02bdf2324ebf9598d8ba918438de1e3343be0b057925bdd52304581ef621fd085dc55cf8b45d605cb0b60047bfa935c2968d554753a615e75f24086b4e40508ecdeb411ed26c007a1110f3e73f504d7fdefb275cbb59cf9cd68bf4784b8845467fac90275f3bbcc2c14a87fbbd7d111441d6ba0833b9045db43975317aee170242b291f8b07254d395472bd4b67db7576bcf2460bf0c182f745a6cbbec3f680b7c6e0a85308bc3af8af3302355757a77a2fe3f98350e4ea1b3074e37a638c630d529141843583ba4b802e089e0a7ecaeeeb42079e072e64fa5251782cbc67ba9d46e4c7f502d44a06e5212d09f4dc5bef1f1dfc376d4a042f608b860971c44caeb3735cd57e19401314af06a73180918af7693ec5204b3f858806e6919af05a1a8c6daea2ee3f08fd2401c082f09f7042a7a0b6b484287050a15f5d8c1011c200d42eb51aff5a484fe1de9feaa264ed3022b6f5b1b54a4a316d3d7e2635210ad83e2d3c497bed46f417ed22804682529b034925dc785a2d74361d1db395d1681d71bc1b0635908ec3e92f850577f35912fe5c173402e6e2ed8003c64a57bb65a2013014a3ce14ccc725f733a50457a696396cac0a551cdda03a2ec81597031feaddd801a9bceaeb5f862a4cdb3eda06dc317a96b29c27d78dd977cc6f25d62bb967814fd1d7e87c675042522c904fadf1cff80289374de8f98df511de975011d877058aa7ea9cdf186ccfa5cfa5258e581ee7ee73e16dcfaa82f079a16c95e6ca4f49c037b2423c12006b549a0b80c5dc9b93685fd2a4bda99e5aea74eafe9d28149e94adc538198d459c7a9a45a1fd24011c69aaa26aa94954fb7dfae2d63eac286b4979e7d513eac8144a7bccc34fb298b7c556a82e7450bf590f1ae658c474ac7c12b3fccaec877b6bdd11fd9655a27b7b69b922b1629a24a8d7f81a6827dd22cbab62d121f5cf96d59be904022360c5bf04d2435c52541ea4d7f932e19fc471a0afb38f2e84668d974c57c963346d790a670a699ff53b5557def1ce9650a8624bb2065f9ce99d6b5361a1b39629040fc897a5d0a07816618840f86601508e90198de64d7091a8bda406009948fa9ebbf2f56adc66e057ab23152504438062867c4237ce2b99c020add16bf66a0c06a072c4fabd9b9fd1146b73366535d934328188798ccc18ad37d30e06a39b8e14468d32820d912359323b7d474dbf507e894a448a4e921f70d1fb4d4ac03b178ae157814004fda0015c202cbc492bde212955236761fcad7de9ec8932946b5352f6c35a242d39b354a1f96a706bcb84174a5b11a7e51cf0adeec9c1e5c88a3f8f32ca81977db668650734398e6feb45cbe0ed5c5d8f77cfa67b78f8c7c856629e6731321126d12ebe70603fe28d4281f5c9165e60749bc8688d7ff3cd509d958dcd1ac7b068a3548af0a3e20b984bf4406a6c6d55c674bc83240757e3a0515bbb626b6b6b863fa7029c5043d67ebcd531601d39b8b6b7e507a176216a264fb80f574d7a6a587d5ebba7c355007630fc49127368f6b672bd12fba956db75f189bd438f5034badc04d396b04a021608a7888434eefffd082efb0c3196698d1b015b42eb6f5a2123c085d37841c0cd6c7a1d77c55d61d76426758cdadcad3d7b1037b5d94c29962d4fe218c9f98c89ecdd9a50a48bc534bc167d536e11906bf594876aea01683269255701b705ac454b250abd45d10f4f8b881c6f4a360014b450132d750099100e15c70ef65ab0eeea340604f3c7b3eb3c51292036a6cc2843989ea418631fb595ca9d7e23a88a3a7d0a8dd5ec1212fc786dcf7107214d5c4d3f2cffffe2b1fbef09816033fdb616b9318b80b32f045ec1bec678dd1500480154ccdd87365d602f0126b57015784abee6fdb9b7f22ba135cb882dfa738baa17233fa53c68d35e4102f185c07a1310b710ff2d63db7238d36487ab504e64e239d0641137cd75cb282e831621f101663efb2f9de2520049c0d08a7c965477fef9d575ba31e101faaea20f96e40046132cd3aa981e8f360cb6a9bac68844d07c7d1741e56b104f634fa1a0c31d64ca4526de4f1754effc40aa04b9dfdb6f53ef77540d66fc9d0a4058fb46518433a1467a71873a03600b47c81e0d452cc44728e3a625235c84f7f62d753faf91fdfa1aea056925168616516e5c4357a7e82c94127fe8a4626c868bb7bcc13a0422e15e31c82935a4e1b9cac496bb456d1318e2e3e704627f1dbe646f5f1590283833193cb03f28e00f5020fbd43c5fdc39fd35498cf4fb7471417e814974331500d8e4ec8af34af39b457d20843d897d0e015456600325ef702c1bdfa7bbb3f2e7a74dcd8fd77beb3df4022320108d0f48a7a9b2a32e1587eb01094e20cb7c002d3de08f481a1d7cdc6b3f9c7c20185bc7ee65a12aa6003a58b83aa90d90baef64c7d324c662ea5138fbb4bd063720f8f9111e20def54a90755537e44fd506737cf1fcc4d0b320bef16eedbd750f8b2002e3af2f477e9d2912e50e4f03e43742c19e6e94c1ccf84ab04f0eff342bfc1020a544d7e745fa08a740418aa6da398bf10870a860427dcdbb857b6b41cec8e1342059eabb84637e61530039918cca60b860ef24c0df9e586b7ee9ce89336d5f1f7a210f0385ce2ad9f83a8534ab0325a42495a9cc4cd4774c9e8910bbf4e7192c2e98001fc3aae0957bc822f2aca9be874e1493a0dbad2ceaf959996060a2bc1781c23520cd178b0cfdcaa92929b9daef0c4dc752225792454327bc351b63765208972820203954fb6c5f5fa020f757c1bac415fec7e1bad3b7c19a93ba5ba53dc12f5ffc6720e7572f4709fdb74cdf2dbbf50a7bec9c10ee302cc2c5dd878bd109d1c87bfe3e20883ea4bb25deffe4bed0029e066230311cd71a4beafa608d43d615652cf9c146204c98a01cfce1f0ab321105b2f2659d375f691e2f6cd9eb821adc512718acdd6120d5f64f7950ad04508b36baeadb52228ed3a1139512125834c1f449b2a9613d67204550d2bb9a9333d567b6c2ab154f1fb4bde51d4b5c50307989ed07100095485720109b0b89090c8a2fb0c51f3f9ca1eab0e36b4d9200396e7958523e57b705d11b209195a60cb9f034fced26b3336b0c49872fa13d56cc410f59463e4f312c93423d20f52bbd6ff9d724752b5ecdb148a47e86c1378111d76e77a2f4434816e325c62920510cf87382f2a4c1417203c4e17511170ac616fca2caa49b521dd721a8183f1120ee7ab5b0de3332ec1fb81c2d5baaf0509eda6de26147b081866c2a9aaa3435882011ce790ec01637ff533a68dfd8fa22733bd7e4fefe18a5796e9bfaf996adf54b20509ea869f679c1ae8b074619487fedad28874cf9c93d01003e9dd0df2f45b66b20380577ba031eb78adb2c71bedc8154056b157ae0d01203c19d7df420a424f71521d04f8ed62e7175b7e70d7f92dafff586e5a60be02901f9f67e97374bcecfe7830020c6be51acd068843922b23415a1c7b39f4846da7584a496483a36612dc295968c204e43611bdc897913427fae390deb145024077c5808238d960f93c62a62f70e6520e2f6ace6ca1e8b6b73dc2fc3c43f3d1e740f1c663e3e0ff9415b2d282e1d377420bf6b145630d553e8c6e40d84626f01964e3d6befc33ac284263ee2c35d76003721a9d895ebe788be0a6988027ae6b33a26bee33016d8f4b64255f320d34deba4900020c8bbe181b5482c9352b9d607eed48dde4a2759d765bc2da53cbc1c03d68da30a20818a0a41e03fcaf75f38766bfa81738f0c8ec2fabed7142e998e7b55e014300120b61bcde758db04bc126bc67a1c87b9fb4f8eb2c3958b9e4106306508e2b22c5220cec0548ab3977bd4bbc802483f0c2ce3b1d6dae43bc0f8c79167e4a76ee6f7522007f74fab71a89f6b94bdc8b5128de93ef2be214d04fbb8ac8c2832e1f2ea6371213c26fe406e69b2060b60c6d0eb7759fe85f19cb2288344239f2a398f04b2b8900020689efa2cc2baa15ce24852b1f71d08200fb1f2ab8e10be0c4db8c8263a85032220b1f2737db4e6e55370ce004319e7c5e137f3567bc4d5603e4ed1f5c636db6a6b206dc3dc994eb2ed126f1a76892117b18b24028dce73ab6d35924ec69df9bc361121fb6e92175d959528d8326cafddb4ee94fb9ffc2d9c8a413898f14cec99d4299a0020349eb2d5c10d69df3bfc41f99d6cafff21ce69323637565e1cefa60173ad0d78201818225053a5e2d51745c5002281935d4dca8ad82ff76c5ca2dee6be84fb767021e8d31f169295ce6052584692ed9e4da87e637ca0b52455f216d5036c55d45c85002100c51c6bcf08c0a3926b2be93b47d0ed8955386d369ab8d6849957efb23904a20020b4b64e35cfcbde2461db3ce57292361aa30d136b0e7bbc766f4aa3ab4a72b66821e802f9eb8168e002b64b2b7ccdaba46e73e5c422dda18311788b19eec7af5782002062e41e97d6c8f4c6ac4c0f978f6f8488c92a51eb5793ad109abee4d6c009256920009f7f1755459578897b027bc2b282466aa1d3b9b0983fe4cdf51c9090548d5120760bc9a32ab7facf7f3c1d1aea1b6cab28bb65276e269472cb24ded949256c0121ea8ed0306eb39aeefdd4f57e98d25cef9ee6288a915cc96207ddff402bc2fe81002138caf88bb834204209e39e6c9430ceda0294f1253f8be81917370a34eb28149200203aa2e1582961cbf9b22333ed51aa4512dddeb3cef2e4fdc3644dfa5145366618203a0ac51cf2e1a98ea343e9ca5fbead275516bc20e9bc421e967be939026677682164c2810712d79c82e75116a7e173af1208478e8ab6763c7ee58551b4f323f78200209943e959b9228e61e5def9b2ea18de1688565a2c49dd98f1c0a7d43e81a6800820c4ab26007fa076e1ecd22f104a860f10b22b1ce2ad1229eaa2cf204815f7246e204090f4ac3d42b2793d83aa79103e0d0488ae1297109a6f47bcb29d8c8b6d383d2058ceed8e9f61bca997b4fef220feba451f5401ff7186d1348aab81d7951b5e1d2014994a638981db8c53c2a364c744009050975bfc23fe7bcfc5c8c36b3df0bc2120e26863332f579b931156f0559025f7f96a4d72a9fd108fbfd5c29a71b13df72520e84680238c100c359efc6bfbb7e97d3ddd4d1d24ef3fe8a5208a714580e9aa552108c2aed76de2cc32cfd4d0fa02eea4be069e74da620ba09461e63b5127cf9d8e0020626acd1bbc9c821f30e5ad5b682272cc4b87a9e05323357a367e170ebdae283c20ff8a87793a1dd9996598d50333ace7856916b2add797fb4cd7cb7fdf24245a642165e43a1aa272317475fddbb370cb547766ee44842ab25d83cef370304213eb8b002121565e2ca6c09a263cfcd17fed20fb6aa06e1e798276a5f0ca8cc16dfc5c309600203e8034276a6f2379f8374e8fbc709a692b2dfdf271c0726c4f890282a40eec2920fa335f7d0543267026ecca4424d15cd07755568dc93c6557f850031098fa55572036cea7284ab1abcbe3c7a83ea779392fec2fd8c1524f95c5bb481c67e300600521444c7d18c458aa1e62e5efc656b27d6396c2fc9299befe67e3eef807711f948c0020829cf5a6319d4467027f9057dfa46e1de952a01233e068f7fd18cdadf9a3534820b622a3b592686cde240a056840080c5116582bf166f4655ace84b34145a78b0520fddc9480245d4ac36034ada99a182ada449373ea0fe16557c3c6d9d55b21942720e59aec0bc8b52c1ea0179ef1bece2bf3275164c8b14ef9528a1335dbe13def6620af95305f745778a84f8405956a82816f366957d31e74af5509ff0b7f7dee737221424e7838cbff036afc7e59ba67c6add560e242a1772ff069a31e5e320c08428e0020f4855f5217b4a20c250b75c69d494ec450321911f9d7841e489d43b3739eca792010f35a5fc740eccaa7fa007f33b028089dbb8bf539f560df19627d5bdba6fe3f213c35de64e6b2d64bdb6ba246a5c9b6a91047e35240db65c383a1be9210b7838c0050fd000184213201216f3c4d446f38e3733348efdc4a4dfd79febf41f03567e0ec2b5a8acb93639951dc506d8649ca6d1926a25b4f19549d2b3700cff07c100c47c456ae23df2e60e27f8822c427e2de12038e00293de92621bc4a27d104803e48d076e3ffcf8c31f549fe9f95c6b9233be396cbd3c557efdfca11fdd91397e52f35d6b3a2130fd46ae505dc66bc987d8e93689d41be09a1df024af243b843e18f298de218ab87e557c782bd20648dc4fd4a5e654f1e9fa59626663adb179f51fec2f5534f2f4929ceaccd34d6928ca3d42fcc5efa32490428abc3296147147eabffda85ec2be06be4512448aabe1829d0219902fe1e9cd2bef29a8f89ed8eb1966b89bffd00015e46cc825850532b9bd9d584441772b3963c554af1424ffa9ccc7d7de55dbef9456a3fbf7b4be985d185eb898e166ba646daa12cca359ea77bb7a59e45e8a6cf2708c16b5ccacb708839eef2ee4b5b6597ba3c5899c9f5214bdace3a521fe36cd39b77952d1bcc81f9e5edfae34f1cce40369b7ee492701351d34e231af8a3768cc158a796e900d0cac462f5204da5cde3abcf561ad60f91fc8951e4fb37166ce37261649f840aa51e6ff9be749d72f1f5b5ae3349f14d572860dcf36aa4655788b8cf5108dff7a4e231d2e2a3bec0a159a1e16c9bb38c4052439f2b6b1a62addf739772a1d51057c89751f7518a3b1cd7b37c4ff7d621a54bca5b3936b137c3fd0001b4a09bfe02e11668f8f6204cb9ffa9817ec412c820b6b394ce2cc3a12546ebc5052ec1c74876f3de8fa22e19158198cd04c1336a792ea47e63c2bf4e85dbe7460501606c97409a906dae4e7ad84517a7955793365ca4f49b5f6829efe61f52069fa19cb30ce0a74d415898e7e134ed8c4106cc9d6be32577e9a024b9dfd8129cb5efb1ef282802fe0066aa41e587ac9ee20d6416010e1b0772a44b6d6d1eb4ddb32b80b288952b26323bbec16614c227e4599cf721484443f56571c7b048e58fe48b1786c44e4979e105196eb9b5803795b8aa3d3e3a8f85e10abeb0f7b43b9a62dbd0905af620309ef74f281f39b241c4b4c109ca7e0ec55b13eef8ee0544c820b833fcfd65b4a897458cb9aa376c6bdccf1032e099598452130bd38d28096322fd0001b6c9b1d3d60a49cdf61f9031694f9f790a4e0118ae39cce789df472c13bba207d101410af89ba4b2a88cff5eb09e53593bfdc69a4e2706633a45c77a6d8f4baff22c7e5e6b32278dbfe97100271b1fd1e64463fa6a757c4cf4b19954d1d363494664750fcde0f0b6e35f9e0f08f4fb1e438669f78b10e800943840b15541a31c3090437364159681974adba314bcac37e6cb3ec97c3d8e0cd52241a4c162787c199a256599b97e488b8a75a46d2c3d8af1951ddac43b0d338e739deed1ba26a8616f04d124244f365573b602697aab0b427a8f26f1a22de77c563cda13fc03a030817a837c497732c6108eac62102641176f62d5b8e73d0e27e17586d6e2c2c3fd0001ca7e381637425fa77d95f92b8b491ea1398f53d763a6ce32a2b2ff972ab74bd377723dde5302c487fcf7eac93b089457761ca341f143b7c5aeb51b33cf103f2f4ce4c282bc5bb8c7290c2a9dcc82fce3dcd9669f660872f602de46e869fccdd99c85c06d1d7bc9af4e93502e4ffc385b9641df8f21a25a14b8e4cf99cca023da529f8689d86418c37dbe3d4116e08069f3d08b9c952f4e2164dada9e62908d57cc68b264df8fcbfe449c21cae576adb8169a97294a6bbd369a58654cd182c8403080b3a5a916c107d7271311a291841f3e35e065d48f6023ec4118a3a88d417507ae86b6c74e6ced02c768e0f879844e22862c357a9fe22574e30c179e2243e62192455f24a5c214eba9746d2a8f6a47625b53e5e62bcecade179907fccb08e4b200218a8694fa7fa94c9c174c8e1525bb14dd946981e66b1c02178cde818615e3b6910021fca3cbe539842d405545a71cdacd7a795b6f6fd1c1095497ee8ee215d5cb8eca002039f76b8f7067cd9cf8bde783c8ea6edee7638113ec6729093749c2cc0c15d13c2091ecd1eb4b171326a6a26d764e72e09160ff2112e577149bc7c24e6c0907324421cdebc7f780b73aef4358c6f7e5c9a78ee95dce81c4c1bbbf570cde3c43ff5ca00021c6955241d2f09d2e90e33601fc139d0603919a1011ea7ee89f0e5d53727c45a3002037be468901ac92d7c3481ad63d364a93e2daa023bbcfaf4f6fa40cc54974de19209178cde568ebc660a7698cdd7f83d5932364eb8ac144e62cdc045a00ccda5201fd0001a3347a2cd4b1f5337653554be5fea273a5bf0292c0fd60908c6e699802079912372435166baf2f71b35ec0e00a714e9f7c10096d0f39a65f66f220057f369ce7c8221dddd00e90604f9de897ebf9f06afd41814b57350ca2d7ff7e08f60c94dc1aa087b7975936832a7050ae4a14b47b1986e70d61223cdc37dfeabc5e4212a869d953450202c9c12db23f8819faad59252173ac11c54020fd7324b4d39673befd2b585437affe9398e7967772d408c6db860ec512f94d02b0cbe9c9d414bb4be4b08c3a420a549efaedaa6f0aed7c74044785b611b2d65abfa33374748adb690a54c8719131e9dda9d46afc9dd09c8abc498d6de657920642d3d47a55f17fd3fd000189cc7f3247c6ede0becf1ec89f5d92142d25a713e037454409ccd146dc18c58319550275149342c690f00b1e060db34bf6ea34473c661ff9f32a40214a4715bbcea59a9ad6ff976e43b325135ca16377be53be39255f9e45ab4b3652d629a30500f9e37671d56952b215628ef42c1e49046476e9a760041003742751b158aa85c4e95f70e267d2631491c60cf09a174816890a2dcc2f8a38f296389c3ea66a6d577301297c242a6b9ca7465d8282f2e72e7673f9e1f1c8f470949c970854134de397c08c06d5d21fc487b2b9529c901c052e838a087f15b802d081e869cbb311efed66ba31c17c73cce1e0fc32d58d35e54bc5524776ee3f45d7b8f81fdad4d621b95f023b2202f5d7c814aa89c27b5e0bf5cd64b444c711c2e1c4b805af4fd58300208214f56ae87b268fd16d6df77efaa715fccd1fb1e6298c1c4f7a4f18d39c2f75fd00013fddc975fb72a66d5a7f3ba6df9c1ff55c7abb5a8f08317c6b346875e850223d87d72d71d9b17c11bdbe6eca34b521fe98dc8d31bef6fcfacec74e5c4a6c5f00ac769363df4720e17c3b687a42f29e8edbc20184dc9274d8546368e3fd2408bb68dd224b73a1487c10f4e29b0d8b9823f7c73db26ed16baa4c5d75b162cb5a97caff3cc8957072902538e0e698fad2e90b471777c5e8d90e5cd313933c2f3b30e49b6cf7ae7dcc09ab2e5e464601f093973d99c0815c3ea587d1803b4ca1d9bcc2ac20cb818f95d9239fbbe62c3356ba41f7dc9d2232f6221447fc4858bbf11ee382bfc639d9101943d958a59ff9b81007508c0879c4bf16885bcd6eb2383898fd00018ef6741f57def1a55a42b565ebe0451f0208762da626acaf0cbaac6d9bcec14e22c15660c2a0c5a23b27c445ad968a7e6b2daab11463040eb50799858b7a5063965f947234beb0d42fe1c2dc7bdce7fcda3de1bae384b62e798995eba4836dbee41a8a4de269c026018a22687ad77985d049bda1d39b79528b320ad3d22d893c547b4dc4acb57fac4e0603468beafdf54408ddb7d4c5db0cf14162d0d735b3fad10888f0048035481e5713b846761838504a36c1f956b072b46f11c7c6fc86c766c36fc7dfd5068855335b96162d8b299e3cd21060fed730310d7748c3d16f228b0eea1f37f5eee5453e3a19ebe1e60e4f2834d3338bf1887969de57ef02a1bbfd0001b50e09d8212707f0035a46513208bdba63a5d1fd7a7ea88e181d7a3de81b8afb4ea4b0428eff67c04b27372d73896fd9df443aa9b2a42cdb8665271bb1c54833454504dbc80645bc02366dc998d334b2723bbf5ee1e139265c107db84774b4884f26b57818d39d05b47d5bdab25778965a0627a96ec303c743ba57c0d32d0337a6d3dbe982284109eb0a7976221816cf3fa7f2e3cf739f30be83f253564af7f411358d9c5340fa5578120c48b617c088d9a3d0811a575bb1e96b746e25312ff6d87b9705c04a10123c7606b4c5d6658244121310ce3f1d062250b57ee51e35e0d7b787d38206fd9d26c70ef94f337cc7108ae8ffddf5fa314e6c4e5e1538fbb0fd000192fa78c12fea8a45902ac86e2878e8327b5f4c2ad9bec7d6167bc5590df49cbddda4d4d652f6f087f2ddc7a2813aa5b33048adc5f900eb4acd983bc86ea7b89f630b647a9ef42ab1bb6484ebc20989603144912ffd80158aff7e7d40a1a76489afb21f5f711ecc3ba613868a7aac4f28f2cd5440dfd064c4874d4e398a4718e10de8f7055e452271f01b27544af7035aed32259796962035d7bc37ef843cdccfacf969a1659e354ec27efda8756b09c0bf855f4fbf7598273154b3f517303b6169bdbe4ede890a90d3b34c917311027afeaca7dcd27158b04886dfd81803594292cdb3dbb77ad3a8df97df63095fd28c275b956cc61faddf770608b898d74ca9fd000138b1c34ee04d01368d9cd8ec4d70b97633951ada508c031470cb4cfb15a62426276c7442e7679f926e93325e287ab9ed87446c144be02e559dd076c9c5e1d6a6a7de45438076b1bd7b9ff5a821070877c1d9626925c1f47838e56bb6982b54fb9e131741bab5ea38aabbe4003538c454980dc3be9a436283edc32a3de0321f114ad32cf34e860ca36f18d476980910e564af57da498cd16d08af8b4d4696a9962adeb298d7af4c8c4ad9cb911d80a2115e833f0856833232f92f94cc9c3a6a14270f4c30ada671bacc9aa35bcd3c945dacbb01f4556ebac4e52adf8790dd49fa799584d227ab95afd2da9e67e5642a90e54fc8b693a07384577d04cc40861cc9fd00012c7977632367de82810aecc22a1a802a1aeb6ac54a4a79d8e61606a6eb72effe1abd74a0a18ef83709355e77cef5666f16d94c0d710f5ae3973bb26d07c0a436aebca4d156d42952cc8cedca94162225b5b4780cf47a6426757758957dcf2f638ead2312e67e140ee8c380949c0885c68b396517ba122d90a0ed184564bbe67bf1d0115c77857fbb29945ea00d8e809c295295494dc8cca091d900837b60b31a79bacfae59fe638ee8a2208942e27f605c452be3a4a43cf21e8e0f78e7cc20ffd2c546a0bbcc404b415e4eae2c7b9c2f9121bc741a6f8c5c28c9df3e0169cd86809f5e235ce17fb625f913f94b75b360f9097b625a5305040c55a4057dc4a68efd0001d594cafd37d880576656265ebb737602d6f3b9d0feb3147d8c1f6e1514ca64e3ee046bea5e6892d8db9c094198fb3f697ae6fc880075bbd461e9aab7425ac3a7fad84071b170739e7815d0c76d2ae7fefb718ec132b32a842c58cde33605488043aad9c5df6e58401994a38dc50779e348cd18582ca74aaadcce6df39b221d4235a88000eb2e3efed6744abd0b68836d56ff5d9ec973be549d52a195195725caf3b8cb683da9b96868d35727bf4b74dcbee838d9c39a33d15609fde1c2b345974a93c030952f52da7ac20b8c26b340fd9c08f56b97ccce21004af28ebe4946a91682f9738085cdd9a53e160346cfcf396cfb59465d114c600d9571634aa3c880fd00016c714ccc93e995491adb6718e9ef69fb2cc36044d9e6b73130606b9c845dfdc56e9518a120237f58fb6f2546bec6f47ea6a11f3d898baa47682fe3f537542fe9f6d3679398064a48a3ef8abc92e89eea84c6d8ca956b3c40e9b82b2a496bb1230f8789fc7b0befc061468049d416aca3fb41d4272304a728322684f9ca6125b91dea97bbbba8c83045b5ce8b3110d429d65998b3aed570ecfedd176e98a91eb3410cd501ba52d176bd2c8d05e94acc8f352b3cbe12adefb82d34e174765ef1197f8a93fd4e03c98967ddd0332733e5aa0ce87f63aff78a2b44d10ac63c1549d9a9c6808b743e6f785173924d736bc0890f2e68c1df72b0fb1632bd4679e87690fd0001e736a64f64d58af8d43f0980d29ba57dccea56ff13040270c926a2703401b83859a94d6cbf4cd62109053c9e6ddd1cc61ac649ebb1243037adec455891642de8f43b57afe96cb63736e3e7d735bbeec8d1dc2c698f37f0bcd85bd84cc6b7e25eab500c03f1c62ec730c24208e2df2830d32842277d9e5c9416a91217cbdad60d6a77a7127b09e7463354266a1130d1f04de9041f0f83d41246766027700fa9a02d10d2b0fcb0bd534d22ed38278ce177bbc1c429c09030f105a67db70011d3eb24754bbb31f8a6a98bde215f635409e4cb8c3d769efdd7f1561976bd29876c8a11a130cbb8e3fdf15fd9ad0329852dffd794f499345c3fee09eee21997a5c8a021b993d7cea74a8935b42df2d55606a8841b1a4a8fc0321701d5de9bce1dd1bee100fd00016e510cf2b787c67956990655ca7ba97aaea25163317e7ebfbcf2681b29b0b821aedb8f9b2e309d37f660ac7169dc8234a7e7e4d01e79164eb75f28d284c52ea3d7edff718aa96db6b0b6781366ab985d202823130ca53c2a8e5186fc18862348952e967b1a3e3636517c3e3c48d8fe5e4ef1d5e230ab584964c888c61393ee3d6e34c50446b86e68ebfd048ac86065f9a9c4bbfc2474027b612dcafbbb0416f12d3d856a96557529d1144a852ade77f50f600ecf3c0e00296576eb49a0b211baaa815cea78e1b7a56a1c698ff58488378722f58fdb1ab8bb0063654a8de8344041fddd04be1b4b2b1944ce9d1ebda667caf9ca00e5c2de892a9063a449edd8c1fd00017140014f19e2474e1cc4b40a5a8033f3ddfd960ef33c7e35432deabd85a5b2c18a27b85477c9e6fbb2d8a5e6c686078009b1f7868768fbb5f569ae3429fd64e49cb3a62f32fada09059982a03a20494bd2fd2caa75a01b3ecdbc32acbbd648905d56cd2aefc714af1c24bd6e8f06b1ddbb85e9882ddf8f0e67c654402bfe2e0d9ba404bb3da2d58305184949ce513b3784c3234b39d25e0c6df741683750cb46d7856e67a0d45f4839b5305f9965808d41cbcfb2ad3ad18cdba6744eab0148dd0c1d5e687b6e76bdb9408766b57297be6fdbcf9d7e3e6918c194ef32bb776b5ab85f3a6a164baf866d93ecc3acaefbac43a9fa267bc623cc136ad712b00d788efd00016d3db1d97d5d429249f5cd6dc35f61d0b1b44f1e433cd5e01d28afa6cd6718fc1432b77e8eb6418b0a4b6fcc6707cd3d5c6661cf57b3b73b78feae7c89e25eff2ec1d465be91a18b2f3bc4311919f410cfafe8ae8a06b4b947f9b6fd8ac17453c2f558dfd67f71829be66250d40c3aad6e3ff52d1c0c455e7a4f646445405cf1ad45ba65392bb622a770ab0f5a57e73d32d98ee49d73ac7dde7dad9b9c8e1da9d1e6aef0f9cd120bc1d2cbc5ad819190b758be385f2b0dd736184382af8aa419ac7d734881ab4728ea927b6655b7d3faa4a1e14caf6d38cc706a940bff9c9021e4b6c3ce516d0257deda35d2ac4e0423f01ac849b19b919b773a58f34a06c6b1fd0001464885fd950d292d5aaa6155164216521d2113d795d9aff389771e8f39ff3d96afb5e2359fc52aa6d6cbc3921a7a21e6f3fab62e748337e2cd212456e2b2f52dcb352a75902ac6fdcce93dcf027138be788aad5e09490bbce637751cfd5bda9ec540d7daa92eed7b27ff1bf66585fbe3b39db3de9dd386ecd7671f2395522c1c9006908afd04fe68d88194cbbc3216377cfc27c4fce55c8e558ebc4943cbb477a1172aa8b344c08d6fb853e64ff0f986b7f3e7cc3b2c3d8b2abbc43e08eff15787bfd6a9a8f98b207d8e2530c0c37a37ffcec2fecbd726b4b845ff48a44c1ec7031e4e663ec0042663ff81b9a7fe7d599695737511a685148fce0c3eb01513bb20ffe083f86d11f3c552efeba225a93eb8ea756f45c2e49a4dd08467b79a14000220fd4b0ae4d4c5a165ab75af08922366e89721ba7ee52751e0b3e64017787b9445fd0001d0a8ddbc130bbf7bcd20bdbc8728e812a45cdbe602dcea5826f753b94e1220cf698c1212464a17ed846b29db82b1cf5373241d4117b07a8b7d279d4e8511d22c47be22291e9ab56454becf533b771e5e602542d07828952d5ef900e2548739d57cbb6254c667bc50a0f97c11b7f3dc1624111f9d32cb0d85ac4106b41cd6d82db7aea1135334220163c190744b6daefa456f69c331facf9083af360db6f2a2c80c423357c8fd1bc28fcfd42db69d733efa9ecdff9df079bde1b73a63ee74e5af5c75b67a4824a72466e17b501f6057a68efc19627d115f19fbcb48c0e0889857f0d9191db5875ad6d336adfbf7f09989b2aecfe868c2efc2cc64af46d07c44b9fd000151620fcd4091a3d3e78e8a1f1b5ac9ae8bdd3760320ed9bea2e1b237110d7747e9894704336b958fc92eb200f06507cb56a12f202a8b098bcec5b7b6941dccc18d2bd968538185dbfdbb6ea61eaee22aa8ad24d73df0adfcfafaec181fda3626479710bc19835a5aa7a3b9afd8166b89f5aee8d52589059eda61f19f6319335dfac7765a9a9e22cce0fb3236eeba6ce250ea0b7cfc4a021ca3c88859f556dc1137349a7ad5a628bc47267ad91ff86174a2fe74e3ab298ae8917d6a57a916f00b16bc0f3584b12b0d63141a20b1ed54c6551c6dfa5647783dd9acc68ed75044faf6745161c1ee4abcd9969ff9e01f14791de7d0c8e44e77a5b249e2da833ad3bbfd000119884821e74482b639ec1f8484eb6199a01d6e0a3606e25527d7a9fdbdabaad9f378a9aab04a153b1003d520d03f25a9e41c82504ad6de9fa6cda30a3ee1128c35f49d469a79b3c190ab0fab92d9977478c7ddabb6a66f291b58756e040892f44ebf6c8d0ba3cdf5d9335c8b05b0d34a8f4e832c54979274f5d4554af2d05aec3d51a3cbe03282c9c104f664fc39863c3a23396e762a5a8b5ba18b3c84f0f49f8b7cb6f627905a4fec65e5ab41e868561dba5cc8bcaa8c201d613eb678342aaba5e5d44f7ad7a58810129aaea2e6bb9850ef022e54a50b18e5fbbb76b93f050c31d279e66cd51a29c42591b18db05e88283e52070e6eeffd8fa447bce2f22eb921b2e3f53f38bb201511b9e1d5bbf22bf9e23be137543e34d81a0b194f373dbaa600fd00010ef2d9c59966c760260612842d16526b4352a5f05de655fd0bf50546be1d893d90f4b8264488467880be2c4d7f3c577e6b68335f1e0764fdb16d6fc44fc5bc2c1660798e6b31bdcafbd33a9edb44e48dc37306abe9ea761cd2077e977a6d5b92aff3cdc33f644f47d90fa9a4b172faf29e264c9d55d27cc4e7b7e5e891adb566d172207406ac400348c386e74716a518cfde36169e4cbd8d3093c8d85b99e54474fb7c7ebec5a3beb18b664b953f4d9037041c45738e87c53d55eb92fe862a78627a8f4f3f8f3ef01102b8df05c9c1d81da85e36bff90943e0dd93efc00edbec664fe16f03c8e79d3e105803c50a606f9812d3717921477e224efc9c38604e932116d048938c5e50af925d722ada7f78de5fb1020ef09d45e001364d0ac87ea4b800fd00015a2d2be1965546e9ffe759719faf9960648c9183812a7db83a24b0ea52bc26e3774ac36fcef82756ad386332d49551a147e87ca68fedb289b965a4088b3de6506378af1f858c1c995313e189684bbd3e484499dd7a26097ee6e89ff1d0b6d4c6c03917c53a95d9fd1b6f9a8e59e1687506a5499adea9c1bbf2f63b14207daebf64c8bd5742bec97f6364560cb3d687f8a1ce12a197e87df8b23eff607c97849672fcd5803b43bc8b6dc2090d7b99299248007a3207621c0b40e6fcecc3f68b2888f5abc842a44e6f019391449a7356f9e2637c77612b342fe61e76b307ffb780f529c6c84a6e6ad8d7553021070499e85542cd288a543bb0d318b7abca62c48721047951dd48307113449db6a3f997999f421c62b4524e5baebffef1ed21a6a1a800fd0001c4594030b3c64f25c491e6a5ab25b959438c11265e0fb2e5b2c58e3d59c86d642aff89f4e5137f08ee4871df4098a5748795d596baeadd2db0b006e02822ae2c5f6a93c5281b82a7d60f8c390a264d293f1769d55a5b1f78f0f65f7694477ca79ec16677993d5b6606298171df85a7a8c9bc74a2438618d2aeb384de706b797758dd418b2b5d59dff089f7d4f7f21a8390a3a707c08ea4a238682670a0d80297ba68febea9c4c5afccc241d9f95aa77366880ccf891174b33d95f462839d2820451611e8453d981a703595e8fc73df0a4963c1baab591b7f3dbbbf62b50dc7bbfeb05ec2ecdb1a63d06ec20c4311451b4129a08b94049263e180411ce7b8b7b120a96a6184af4c9ac706b2fb01d6389d94cc4e3930925a5b2bdac9e96dbfa42a78fd0001cb6e8b1c0da834c4fc0d797fc3521eda3ddacade8a4ecfeb518a2e2e8a234d2f6901a7eb4d117404328c70a5c24f36236e88eb1dd19a7e9cf4ec7582f472da053e283dc193d70bc71867d2a348521a860fccebe0b45958f1b5919cb833d79802640b7665b7ec45135b24108eac8a882121b6e6734dcd327b506a434cea9298e6067c36457858e3c18d88a320ce33cc3861f5329bc1f9a8f4af57caf2c134051b12dbb58e309d7bfe9028c3b9b4179759095fa531cf20bdf18134c517ceddfb38b4d94849c3d7eae9b46c353bc43d2f8345e345f818e328875c1bdcd754b706258779c7c30592d0a4c4b5cb557eaad484d1cef2bd4d98d12e70b8fda33f3a5e8320486c47e0fc0d0e679b413041e800ed28ca862ceec23c1a937954131b960fdb3320a3e4a1a7ded3a425d38b144b090d9fb0aeb9ba631622041a702626583d41142520f6fded7e7b2408e70ac4ab0b4d23bc2571cfb9048b0bc738dfd0d6507549a451fd0001656bbe84db36e12bf3c78c07ba3f561d2c2687aaeeef2e2da17cb00f060e025a993c12551c12bd4d75c4c116d18951515f42c5628b0858d85b8afe5deb0aa14a2e33d147eb62b9a52bd4769b6a97382d6c39e5f70e727ee0c9a4684fcf9a03e7232394b4ecc6a2d32e27fe2b436b1081bcb4f3c8bc844e9cb1cf9b828bfc155a2467d506be2f89c9a36bfe2d19125fb21666ed4625cf881ffba75e67d209fde2742d5a46634019cf1f96c1e649a9dd58edbc374b9d6220bccf20055104e8ae8917537fd07f69e12e0b8200af3d924997ee33a7d1e9eb3ebba741f0edd1b59f05e1f279d0c4f7106276968fac8055088bd5f57840438a2776bb21693d9587d188fd00011ae402863053cdeb79711a4c4e7472a1559d86f89bc4daaa6865eced1126197f3c43d78ba16fb2d6508632ab4712b13c3f45d674064a51867bcba96a71b7683fa69ad337fd0c50e7927b913f025fcc47adddf1376a053280cd0fc45bbf29767b4555bbc4054a824c11dc93c3648b3e1c2ca42a5dc5f536eca084370ef65c5a6229bcac295c3fa578fc22eab793205cdc8d37fe3cbf7dc6e1a7c53ff3c7e4d226f0a0d4667bad282c625695d4a1ad88931ca894d4931b19b09c56d2f72e72f62600c97348ed8814f0841baea716d1e0d90f26f8c3932a1e66dd1e8c8e039d3e891ab7be0a887c16ea9fbf3dd7f2c7200587ef56cd0e75e8e828aedcef6198e6d6fd0001a265b258bad27b42d12a12e43a25bf566f2b77f6f0924e8e0c32f294768fd6d9f92e5a15dcc94a2067c71a1f9740700ee0e7f626d35fad2c441b176a077fe681515cb0c8613b0b43c708895c9ca5a41745ca87cc5e8d02e272a484be75cd9afc7478b98bd4c030c3dd4885c5214efbe70cf9a1f8a2616e2eadc150f979e1ca8c389b6fa288889464886bbdaee7539c6bba71ad0927e455d45db2c7371a5b15ccd8c7e7e91592e9bd057d85a18a9a9d1165a84329a6c7beab031f2819cf36a26aeaa4cbef4e7871c472b9b363a1a5d597005cb98e6828a7b6b7ae81cbd036f8d8afbc6289efacedbe51c10f27462c81525b18d119ab4527d9c6db52cde19cfdddfd000143a024d1136d92c3d0de09ce4a3837fff20ce4c4307fca87b1a09acdba6a1df122cc69dca4ec3ca89118ade730ec8959d0dd84db0eff5ab2ff71793af68a2d6bf3a301edce9cf1088046b3177c18d90f6318e2bea3a071469873d1a320d5036a6ea1375ce17113721f01852e1c436745536bba80365d2ee1060a73098f99983d18511059ecac21b84131d845bddfc589a1a4c195ee1d89ba9845c09d681a87c3fe2322cdf571b4a31756d2de38276b97ecef325f4ada73b747d78899c3f84aeec26fc9732ef5843f8b2d7af0fee0f04e01f85731eb9fde3f0a69c4c0aad09f51db5cd03db3627cce5d2a1dfe9910817efc2e53ebf9f79afaf09521a3f14980cd20d6c0a0d48152ef8efbf7a58b809f5c28186addc9690ba0857cbf2c96e395e92afd0001781c2002615bfb1b7d35b2e208a1df0bd8f95f2d639422c213683828227885660bb231058546848fd01763a1ae99e0a7a1040a0f398d8171b45ffc4a58a4439a5addbad802fe79c71a4a27fea8ed229c51d6cb26c3e21127aeccd2f0e31ff1c7d38987c95b917d4c86439a7a0d54b3985ccc3072727c654bea4d473676a45f13ac693de273581cd6f864fba7ff00f3e61cabfb689689c49849419ca1cf489cf9db2138f65c1445b74a0e0cc83e8368e6e79149e699b6e64c77c70ac5156bfa98794cd0c561732fc7e3623673e1f0ebc09de026d9745c4986d498762be6799cf99143fca5d69d04283b504c8d8e325c8811853d467b72e204417c7e35064ce7bcfd0001e42f7a528e3e1de18bdde6e2d9888886aea8ab8eb149299c32c68f6c93a19889efc05e641037687a091933cc83bfbdfac77d48a2828bac6b0625260d4b9da29b0d215d403c6d3aaaf33addeeac4d46875cbc4e3edc1a865d6fe0b6a588631188897cfd131672a8c5d098c0ff8ec5b17bcaecac1d78f83b5296408c994c1e81f93021da25ca403ab6694fb3e82ed260e88067e3edcd8cd0e70ce4c79b49962096eeb1eeb2b17be69033a338ebfadaaa1293f7fe17fb7ddce051d9eb82c84d639bab4a415353ae82dfed2996a26d184189ff1f25d3196c67f34ae50a39bdc2f711ed6468b364d1f0ea8eb29f486dc6c2f6dc59a24acf4772bd542557e9ef4b5b93fd00015ea90490404b84482136f214f4497c09a0ca87dcebecc03ac3152ae1c3e87b945f721137c7a309e1036d0e5d94c6cce38c36b1645a62c7160ca45abcfb5c165eb696154ae38684002a150ab45f1b8f1bb69b28757e2503967a1432090b6c90ce7e3671860e40e95200f8a1ac1ad927b49bdc0a66472eac7123c383ba28578b149f121ad8b1ead1e1908858a640ebebd2e1b8a4f787e5f41d573168493115448edb0de580a8c281b783afe2b62ac6ea243d021187367df9fd28f97e2ca7c8856d89c64a4c3c5e2147aea8120b4bc8b2d0b8ec5b7edeed35d24a800760e82ab19cb7363c7b2fcde6200b8e63e2b698489e9dc0bc4c8f1ba9ff68b59a7277038eb920fe94cb66b60142227c026662ddbc3dc29b373c5c805c365b245c2f69152f1e2b2007e3634d06ba18b973add33bf3e23a7175729a9442fc4213adcb0e5a52e2cc272096af7547b4220ef6335cf5fe47c75896ef2d27e58acb6d7b444b3645ad1a9370fd0001f3642f6dab0eedbdf04e554106719fde491a9bfe00e8228f250e0035f5a95782bef5680a15e6148467d4c7db9e22d9a4bc766ba884940645845b25ace95d405744929adc2c63e41e4c807e33a0d514919c09e855c9d77690be00720d83dcf2b2276e157d39b7acb3ae262e65a8a09ff49478fcd67765dd03b545d7e83bf194ee6b0c5f83c41f7c470e0a1c3f1014a7afc2b7149c01b3f1181eeee4ce8a9be47f0f7f897a05683629d6164fb882e1b67765e7560e7f5c6be76ad9902a755c6af0c455156b93f14e618f533feb9d351bb956da352b7a9d63cc5082d6f9768d80f1bb703c84be2bd75b848f06925f47e226c424c0ae8ad4293b0e9c330cdb16bbddfd000158cc2778c31d0436228349fd19e0428de5916401bbed1f3668014cc51cc6b42381c32e5a7d5ec4d194037b0544284c52e151b652e2733b17065b2a98fc1f5ff30a8acf5dc7522b64eb94a8a71526e2aff0acba058b541fa412bb5344eaae4945ace66b4f9251e842e4b52feae5ad89ae1ce3617debbb551c09c87c6e6e69b7942f1178db84dd758fcb3f63d658e3f48c1a47b725cd2e003a59b8e318950a45fd908c6c4d53d706dfcc2041c046324e3925c9ca032f62cc825f0c11c9fff3fed99f616837244c7d71b3a8d79a8d5bba3284280b59ca1d0ed044801b7d4d6148f014c8c04e8859d9c3d074a7ad77b86ea0ae39fde03fb33eda53fe5c1728cf2daafd0001991691f21883d17fbf12b24e86f0f639967240dcf120c01713ad612ce32a7b8a9c911248414d8a2b836e82c827270f60f154d5de0efb7aec5b3db3f8b9cedbcda84aedea5fd0988ed0069871fda9db5ec2a1ba0f04592048f0040599023ac01e758886daa3fdd9190bb1c8ea29898cdc711e8e8e6c4497ae95ee2e5a6730bd8388d8fa812b21c9b97af84a7cc9f790139b0005dc86efe16436e63982fe8d8ca6bc5bea86b2297a0726dab7704fdcc8a3f6c273eb0f8aa008ad1e6c4a985d7cefe05d3b24cfd195d2fae5392a48be5bd50386244fa9002f95ce18efe97be356a3c5b3990172f812987d0fa10d7fdf9afcd20e165697e3e8f1fa564d1f03010f8f20d53cbea6789dd88800af410af54c3c346483fa085c6e02c088092372ce828e2afd0001f903226d53becf91fe3ee0e556a7ddd652e179dc3c2de5d7af38f380c9916791a37e149ec620429b47e5e0c016b898ee1dc45db857c93a718daeab3167c3c336b22692da51bbf7ef1bc42cba25af0b1fa89df2adcf41535803ad6e80ff1e1f57c80514f1d091040aec55e87c4810ea31ba22e4f93a101d71e324cfb2d84e381f1a59a601ea97907013e119a24a468b27558b68de170690e71bc3c2d7361ca7f77d116b642516d9cb128a70d6bfba83b2c22420059e8c22c9f794f2e144a3a065c942d3276253013efaa88a80e3d7f3c32dc249347a6df656df62d4f9482cc8470bd1bebda925d47c5cd9a54ce81d7bda23c2d0434a98a1f73911c6172facb08a21cb1b1935a2667416f5cb810c787fac55fe8e14146721452870f27b1d5a14fcce0021600ca51450ca3b29e9ff6b388f4df5286db585d027f68fbbd1d4a95fd756318700fd0001b941fa0b95cbce10d49d29c80ac6c45bb624fbcef94b9bcc75d3593a5e11ed85488b6332f9d059e0123d5df6486dd2f57a9239d137d46f3b9c0120d391d1f06cd48c13a0b847020d0832b15162461811662eadcaa2757e2b6b2240d478e7411c7e807e09ef824ecfb3681b49aa72a3319dd310d8efd930ffa7751a222e73f198032f2dfa00e978c9542dc476ca44b161fe470a5f63759de5086a18fc92aa375c608841c6ea41ebc6fb86dea22a8987f7d9abac948d5e67a173eee3b9b90c323c4f2624f53fcaaadd79427a36f560ce6cbd99872d8119acd2173935b0331217e33ceb4a0ef314e45c1a90ad06a46948a38a00feac8f58d8780e6d15ff6e013dbe214cc25d39b2def7e68e2d5c7afd69c5d629265f750b683dd660040dd3220a2ad600209901a1f845f602ecd4f341e7b68c6787561b4f18d7819c8b319dba6836a42517216578b022ea0911e03a47f0814046cea045fa63bb506730b0b491614417df91f900fd0001ab0bc038363d54f8e9e8ebed6a498b0f989c8ee56c81720bd66f71d0d97477d0db56d4481cc0e57c2316f4bd3a6843f86e5b28152436ba23f377dac267c7bb6501666efefb705be623d0491dd42a1a5397f45b6be6ceb1e0499842914f56296a34f304320f5b623ae7e16379d89394b7057d1b92de4c913265ba231d81dbf9e4b586704803baf1cf0fd474a721bd65206df02888dd77df03c831f8433f3b2c7cf7e1211c7c85a975d129f33734fcf77c09aaf68b681da7c506e8c89ac5394589d185117b722b757e307ccf31e9ddc1b9131633bd684ad458fdef09d346566eb4df920801ec4ac019081feda518cafe1ff9f9197b1473ddb18d3349652db870e0fd00014168b6756041ac27a58e4160cdc78c2cca30e144cfac7c58d40b5521c76ae171399e4e22bd3eb5a59d0a44666cbd8d38c4983f8e2bac6fa5ee84c86adbf9679bf8631dec545667463df5fbfdd5ff2d0f6f9021a4a03510e291253bd133520d5366e3c272bf8ec41a14b15aea97c420ff263bb52dacb3c3962359312e5a4690483434ba5f592057dc449727f03768f3756c24c85814e1204d2d90cad6e656d39e7dd7b9c4bfcd103dac62415b0d64901b01278602553c149f6d64342f16757b5ca1033494395404fe1ac7ae33d796be12b0d4f986de23186ecdbdc8477a742fdc973b34b312f5984b74faa42f5f62dd4938f76f5d7ec141249902825d81ab2d88fd000193a2f7ea915b7e506459b8484c43d305deca93a84b3c313a6cea7286d485395158806c3d7f3155f3ddadbfc913cbd7673193a951b356ae208319ecf406172cfbbad940f9fb6accde0c73ea5a19037d5d8644b4de22db968851a303717f473c491465712da34aba4e5dbc31361163ae1492bb3435779569598639943640b6e14233d8888aefa72580e22091b6848592d5b2273cd2df009d614da03d0c0803b4320800094dcbaaa337fd210820e07a8deabbd595d2b59b3bee5c7a27e585a1cef44e532de83f08f3df43d68f5934ca69776b8ee8ddc42969970d64145c093d4e989cf32ba71f750cd03b5c818c33462d05c6477ec1f0ec81c48e4b119d05d31cd5fd00010b1cbcbad09df7edcebe79a913033895c66686f078402893059e4a986fb76d28e89324f5b04ead2c2aa0a05786faa1e8f66fbaf66b7ca94bf52123e6afe4dab54bc33e5860e8766031dd256edf40b294f55062e613cafac04f77e284f5097e88cd7aaf46fd28f4fc24ef1ec1aa0bebbb3da5b504e4dca8dfc13ed99e4371dea6f3bc0f78cdf6ff47548e5a426c92095da045e5ccf45f446116e2c9567ed01d62c1fd3a78c62aa359118f77705174db6a4faacaa0b6d49b4da38e42b20e9cd2ed7979a0f6da28964e0b3fb755046f0a5477dda7465e00d13c8751c36255b7f922dbfe108a14e359257ad607dd7e1c9ccf330779135714b01746a25ddd11e565c821713c8702e50956f6f6ed9c283cfdd08938c8ccf08ada04309e47fe9ddf5cf5dc00fd0001f5e2772a310c56c1343104f8f618a6408dbdd5e421e31856f271fb33329d5055c4c73e06870feb8f7b298cb9b403dd6c4e87dd2137ac84df583c58ffd627b0c52996bea39da5959ccce7968455d3b4ac512c3d552cf63f2da74d6de8e8f037eca47a715d0d9398502b287e5f1034e536a84839bac21ae9958da81fe54c67165fc650f19679c9a628b74fb40c65257dc5b50f690263f9c1eaf41c3365c0e23f91aa7ade32a352c8b88b40b2d7acabbd085185f7737be6f5a7c342e2c908e8a4f997ec949e4bfd6ca7b3da894fd4581c81141cbfc01afc3eb86bc91c304c18c1dccde49d8b61e41d922fcb02162e444dd4931a076bb072ee3a6d5cebbe8683e992fd0001197c654987867c3fa8c71ce3718a70756063be0179e55bf302daafbbdfdd5669ec38e140e3204b260b1d54373f84657101147e672120d735c179fe2dc5fdf7dbb8aadee63b867e31d9d1242e45bd23ed510dc7a6d44ea7806159895a2293c0898bfa6d0018d9864f584a59003a8d4e3a38eae3ec7c9e35d1e5985acd544547ccc1e31024f05bfd5dc07ccbf535b2e0a0a703548e355eb9d1acd8a59684ba0fb674075011c7be7cc899c30ea6c1df0dc34c9366f47743aa12991192cc836db7fd5a3b0306d6905a82e13b729038c70fd3bea01def02b93cd2e71439a9fdd5fb93da6a72436ce9f5b431b7e18c76d8173964d5a02e24195e761d888a6a2532d785fd000177fcd4e94194a3c34efea9f8f04faccbdeae9f0eb8662b0195db08e2b41a08fed52ab7060465ecebdb2eeed7a768c4f65ed7b5193151a56c752cdf97cd4c6be8daf95c16c6703ec95b6cb541506c715983be6384031685d48190e611ddbded6fa377affb759a1f289523e56761857afb3c33b68f743c59a6bf2098a2f5bf3c1c8a3d41224dd12ef247874ff5ab6257cc3e29a65a1c16f3ecc26108142d1899a340be3db55556258a348f51ef61e8e73fa9db743f08df3d31919f2d9484603f0281422c2cfbd66959dc138c2176d6e242a5a04d5b1cbd8c39ed051486869f8b0f3ecdaf42498be6016dc9d49ae52220612a32f50259fb9a8be0cab88deafd56b12024a02c37de6161be5eee8cf63c3b0d16d0bdca07ad59fa0532678b864c13bc20c87f061046d5218e768801c69818dadd24e62aa4625d5a2af09b08f9eba8efb5e29fc2977184990fcd81a0a3f896691051da425d7b99292d8ad7a3bbae5ed9503c5b761907097dd6457a5444b9c85ccc829d5901d46cd917ead52a3316f96588a7b508501be9f69c73d97e0db9d615f20e50712f377663f6c47f42d005db72b225048abeb8d6e3a60afbe21cd13d4539b7d39806952d745a7c49a552175135d840a3f9b9e9ef6ba3c46e9f4bd42f9bbde0d5d5abda8cf06538d3492aa856d8708285c0aa99567adaf6e0d4b28feec0a55e49879ac8ee966e4520e5eeae3e6b4473cb710b4213973850ea6ff0331b04ccffab2c2c3f55ff28b8bdf644e4c19982916d01e28d745302b079066c61fc0e8e1c90931f122893f7f5eb86e9111f98cf36e719129668dcc4718c52d0c4c6a1a941b939e5e744d61aa9fb1aed6eca5fe062cd109b15abfc5851ff77c026e9ba7023b298d40acb1ae9884671ff1776aedabd859f2a481eb18b963afae2e2ec41e3f3671e9dfaeab234d6aaf97249f4702e706437d8e36f517b3b227bae68b79064a3fbe999bc2d75912d970826908dc17b815ec0f030e20b24f17c527557e36695abd05f67c60475a0c74aff42b5e7fd13efc3b1c3bf5ae6e3251731ffaad110c4210c9d6d78d69d6f68cc31ca99fe783a12fca506af01e28234f01e1b30dcdccdfbe696bd5703ddbf0554c8c4863edb0f252e2a9daa54d4900d8fd6aef61b8a8f1e165eea79a3f20663b08722762f2c548e0cd10cf0d2d8e8b9239b638d02c49749d55b721a6c81d2554b51b49191518150664b7e4dbde3ae02b36ab3ec9250290c8cee6b7371e0d3c7ad64d267cf463c4c82cc5a325aa72461345c4f45c8753de23bc148527b0e982511bed9e4bcd4cc37e1e3b5089373fcbfbef9a111c96cc79e9f37d619c0d68598541f2a37ea0fd614ca310c915c0d412d2eeee8f7d71e4a250bcc60d68ab3f296a5f75d75d627e5913aedf3646f733701218878049c420ce0089ded11b087f16111e397d0f2e354f1df0a858aaf07eeb8e80002210305cf1786fa950ae9b553390d6d62e2b285ebaeb978822439e0922403f9cc7dbc473045022100b807fa7bc196a7b2d7a3000e5e1870e2ff488bfd6e2850aeaefb3c606f28379e022009c3cec446550e5cb04483404a677c4b8406d85c62cb4d714e5ca3a50aa02f260028e8073a480a05174876e80010001a1976a914dbe6d470fa9fe4d037043533eff4f80aeef0c8d288ac222244524271336345713233515955416d48736f634336664452764a453753723548455a4000" + testTxPacked3 = "0a20b65181decb00e684fef238776a0a129db4e1ffdfc454f6ef323e5f7a8deae6a812e1ab0101000000010000000000000000000000000000000000000000000000000000000000000000fffffffffd8a55c2028655e8030000dc8ce67bfe1851477371a9ac40b6ae0cb8571f6e2d5285855288f6079f1ce7239ee8c85f465b1820058b79554f41af297e9caf95ce0084b7c35dea0b95e15a2fb9f8e62c5427c1c36120cbc1fc11ff344909079335209c6b84b45a9211cac960f64e9432ba5eb6e4ecb2068223dfe3d85b345da17bf374f9140c9577c148bcc431c9ec3c7d13bd2363dba821381ed9fa0614416261e88330b3e74c40e6561310eab3f26f092e72f3cab761f373d02680dfb52937bd9515be242f6573754f8f665523cce3bd606c8ad190954f8181577fd0efe7cc64b711d03774958df4a5211e44870302056557777951d7ff8c002161a6a59e979f05469cb31770bd484be6525625359979220eb7e9912e835065fb00fd000216aefbae3525166510814d1636b76b0d48ea3cd54a3a17b136a84340989d75f74ff952966830e4c0d59daa006d5a7190978270ee9475a0778afaf002cdce7efdbfad630f72838b5c4a3b538ba61b94bbd9e353437a50725af5f16fbcbf36bb34e7da54e5c24dfc90b545f95c973877bfadc2703ee10585a1fc1c97d7377bf41c9cbcd5a313849a3c826e7c1301083694e6dc05f46899a901ab4a8d7f6b3600df280157fbef6eca4c28fc610957a42a9acf7c4d7f9846ab6b9b04fa6abb5fefc168d45f10078b97d4d6a39638588a1c19e1bfc472657861a902c2d52cd32fb0463746f649ae88bc0602dbf35816fbccf91dc249be809160cbc7f8b6702d6cc5b81fdebd283231f40758afc899f6fecedc51dc4e5d09cb8961092220541f75ddad45680ea92b4ee78c29f58c197a68420bbb25b450c72d02d7249f7facf9927378620eb36fbf9b4ccbcb55627eb9cf905b4a4c65fcb77a537f642f10901b6e94afa37e4afb0d6d91194454a9c2dd8ef8fe4316f8594c7822a7d58cab09657cf501da5be5a44f947bb957b71e4291a7fc60cd5cef9f0676f7c89123c7ff1ae2e6dc001b6f19785534e207fed2bade8597541b13714284f67d6986bc616ef1b0adbe415242fee85acbf482a6a48b3f142ef7ddb5dd1c97a4b0c53c6ac7aceb8c042d9c9ada1bc986b8c276d07fbf8512a3dae6a357fc02b167eb85000040e8693e45fd000200e23abd27b258f9827ed58545a507bd465e255e1156610da314bf7df68b6b55129df84c7b19e362751ebb9beba10790c9c26c5ddc7f087258d81b006c0d2e92be0178bf5edf6e78e89f73cd97746afbb2551dbc97eafe32ae62e7f9ebcd14ad69faf74d2011d16f2c50775f4f499c87c3c50d9d5d486394c2a7f462675d2a4885493332e0610a78fc0c8b08eda42e4bfe93b8c7f80a911a7992a1deb7cca2e40933e1559815688d4e5ae5e58d706bc513e5108449a8393928b5b77ae73cb03fe212c6375b6c5e61fce9db16360a147e7f7fcd49e05a99711d4d5799be77f7e39d9d1397388d6680d4931b48798ce013256a586781ba80168bae63bed4d150b64a73f7d0ab0c9ebb42f5d4db40eeee303783249af4bbf334c660f8c084ed9a2e5fff8be230940a4a08b59418676ef005192365e4e67757288791ce4992b903a31537596cf6dad0be2af2418a6b9cc2c33e99d874168f6a29df189a869b16eb5d24400ab30e4eca9274114d646aaaa8bad45832b6c0ded2bfa698939a8af9d0af380d2afc58966afe0f45483ecad0f114b904cc2fafcf470dd4fb8f193795e8afc3243b4c946d5eac82babac6feaf4ff10bf53acdf8347fb9fb7a5ac4efcf160f0a7ef3576f439404a3078ea092f46f408a955c965344023d847fad7374cf145cadbc8348eeb2c5aa999ebeeb8a5548bb14e0092b184caee354020c19d66cb213fd0002729bdb186c4c494e17fd6effe29cbe7fbc539caca738d54f9ffc6e52a35a27da134192e2f7f4ee2a86af281b78670e662677e97a1ef008f10f42349fa83bf7841d88b1a457d38164a383ae9c6b974137d58216f22d135d30b9d6e7a74952a7e905f385141f4df088415d704cc3b03b2cd600cb5507f8ea1b53fc0e73031a3946c0d6269f020c9c26a3be3bfdcd37f8d3b9fd42538ebd72029fa0bd8eb57a4fe6769e1b43b5d5d7be311e12e52ee9dad67aa988dedc80ad616d7540381993d9de91a7fac6d08e4414254b9d1d72940fec032833a6b1a5605f4b62c47a86d70dbec5ec913d0a613d438cad385fedf24566bf79edd17238e55421520b95772224623f145100e663b2ba20161784f688afcf07a900ade1d48060d21be9ba9297697891c2584cb99a44868efbdf65178592ecfbadc92f4883662d6b21b7f266eb21815c7401b8e7da061e3258dd685f8cf65f2c2e407c913f85d053b05f6f92ed1299186632ddcf175ccbbc933044bdac5e10916917dea1146f77a8ba4b4fc8ce260b5deed395ecae9b81baa6b385fecca5d2982041c131ce02a1dec517ad2d459434aa3a514e7a4c6c1362401b1ab62b4c89bd7705d5072e0be5250c60c2fdd946bc73050d3b8bcfaa73165eee3660063f279e824d1e15f87307a40bc9e1ccc0f7d7087ba84fe9275742455241b61d3687d23eb9d7a9cc18072ed8be1492db46a454464090750eb0a393499a73d23f4c552ec6ae425a77f97d4285a7f287066b2198bfaa99073da6f4009755e59838e48cd5fe692962b87da3ea7e14b34b352fb2a4673eaaaa594a094610bd0cc566acafc21891b7b0c2470bbb338f579231e01c064f275c5ac9c2748cc50e7f2e36f2768d2a59c22d14b6b9a431f7772e716731c55ccbf086187fb15bd07a5af3040d468d4088ba9e6383f1df6dead9384758f2da81ee96370d9055ce5a0db56bbdccb57ab490f42a01e083b61c5157b3c00e2011dda865c7294cdfd2be5c03a1a36a4deda9cf03b500fd0101b3c97046a717a2f38fe2265185f4411cc68cce6cf9885a7a8fe6292eb9e3eca69fb6249774a8c82b888d22d5fcdd549846c3ebcf054c6bf07aa4d6b1c0d4d9bd8e16501f65373ceda249e9c760848fc86ea92fae7142d211c8bb4a287c91eb08cef7678ef1f445f76f81d464eb1d29ce5c6d6286d73d49cd0ef03b65376eb146a3ff69a487ec90b53c11cce613a586f22cd56fd34df6f7ad64fa3c68a6ae5c9dad99d4a3d2b0974ea1f627be4f0153c7b5fe472d0c562556c4d8d1c7c592bea45bb7886b74f8639f9487b2f6aebf4848b5718f2ce65b8f5a4efcf140e2857bc9c503f0058f9b6af16e75f2d530fbba8979f81569e6cc0bc04a54e30de4e03a9300fd00019b881d73a78b86771d41c0c2a9ebdb3899727f33d2d4d81dd27b6a00b4c7db265999b8442800750abde7cd97be0c691ed06b5d40da115546d90d4803e82c61d43eb5e2bc684adcf180be47660870921fdedb2ce43f33564541fc0debe175c6c49bdfb51378902dc709594b9b7d34d0af70b67c3c608aeb5185e78c1c39cc3080b71a36115f623a07a4e1a3e1f3e17b6f9f695f1a1acd9dd1319d0a0d67b337e64f720e5168c09196244bc71b083f302e042be19b6aa1f8ad61755f4883c3a1ae615252b884ca3cca5e18a023ad6725f08f9e0ffd60e7a73ccd29afc910d60dc99c06f5953c3e398ef615fca45f6a83f8a0be653d31a7e1a1666cc7334d9cad51fd00016f7efa38c8f9f478a6ea1217d8be9b7f0b01c5d1fa483c26e403eb3a4875aeebbd0e7eb8aab8472a5bd80e8be38df13526042952f813b71f8aeb4a2281bfe5e5d9ba70f7e4c9f477706da922899f505dd172e260ce5f008b59c0590d498ac50810e9a38d35f1a2ea4e9ce8f77e46d0b5604dbf94629cfb8b65453a0295eeca9d992365309b7956481a8c5080510a09183bd3358fb26933e15c83fe2ca6d186e631d72889a09464f5dafdc8a93dd100329071e52beb522bef1af0fbae0516ad3b02011e19a2b2924791b3f22679b78039c8356c0e6676e2451487f056d0cff064f55a992afba08f59af7606a809394772ff85c4c40673dd54eb30020c6a5cde3cfd0001ecbc47845e4a1f3c05c4e9d0a47e5e8996326f48d7ee1bd0e432c5f33fecf8a94feaf03f2da65525bbcb119c7928456c28f31c183a21af1acfcd9615669cf47a077f861f694bfc1831f1a71ab66540906c62b85274f63abcc53a37e3fccdc8563ca2153818b6b796473848d765d1c81d4e6f78ef8fce804184e04664c5c6af7c3abe4d92aac3f6ea99b84a3e53a7bf7679c26dc96804ac9e1443b054e3c55fe89315106a14646d06b84122861ac0ac27fe64fbee3c9807b0264a90eb9602187f4df2cc0c62b025ba70bbb30a7e43d533f32546d2e6f97537bc2d623a7f545676a37cb511614037d77fe21a35ce4a2275e7ea1d85b7bc19e477c61033f5effe7fa5d8ff48e2873845157b7a29718e6692704a9d864301fa254798555172c6277cce4ed5aebdff9c27a1bb37071677c16da15d6c1547c5b708e1988eba68befff1f1743342ad7cb89ec8731b567198953d965ef6e395d38b410a326ef3e262937c763179f22a076d17f848dc4e36be38d838d5788f121d771f23209a0d50fb3d016c5cbc47cec31069f7d22bd78691662d8b609932b9533bf828e213e5ed4e61f2b80f05acbc00fda0017bdc59c8a6a23d261cdf1dd10e91523503c3375a803755c29b6a84bac626708b7db937ed39f4053b5c6376b3988fc9388cc64dc07466c67704a32b703d5dd86d8fb8597cc2ff7ca190044c66638028855f29fdf2a0943c64125e3ad2487de6928bc34088957eb32d843613e6b588a91cbfd8c37c24ba655739cefdb203bc3061ff93498160c7e949823b0947c68b6c51dcdf038c538f50413266680ee23817ed9cb840e0094f6fd2277012aa2c6f82b086242e6332a4e00bb9c12c153dfea9340e681e63d72551f8830b2bb2587e5937685252928894bf90bfa174f62bf7ccd43415a094bb4142fcfe639c62f6eda0e4d7ee033f49f51eaa6a35b0f7f400992d8367a275e9d018a547430083c41a35ef543bb92e159efad39c5b84d127bc68dd581c308afd5f81654ffcdb3317dd6e21d5251916214e873c83b6ac197dbac6c1b93d79b9dd5da090be204b9765fdcf662c9296295610e42a0570a1503dd67c44e17936946f3a6ce61f82b13cf00a0b47d06f2cd28651b6af32ffc58304593d5ce81159ccc952ed980f93182fb468ebccadae4dab4565d64a5bcb3aec7a09d681fd24016a4905a3a143e4d61b16898fddbb0f9d7602ce865716b62ae4f9a37e2f6ab89de930e066db6f4a8667ccc4e79e7ac760642c33e5f24266540c9b4fbadda4c0aa1b6a74d02bdf2324ebf9598d8ba918438de1e3343be0b057925bdd52304581ef621fd085dc55cf8b45d605cb0b60047bfa935c2968d554753a615e75f24086b4e40508ecdeb411ed26c007a1110f3e73f504d7fdefb275cbb59cf9cd68bf4784b8845467fac90275f3bbcc2c14a87fbbd7d111441d6ba0833b9045db43975317aee170242b291f8b07254d395472bd4b67db7576bcf2460bf0c182f745a6cbbec3f680b7c6e0a85308bc3af8af3302355757a77a2fe3f98350e4ea1b3074e37a638c630d529141843583ba4b802e089e0a7ecaeeeb42079e072e64fa5251782cbc67ba9d46e4c7f502d44a06e5212d09f4dc5bef1f1dfc376d4a042f608b860971c44caeb3735cd57e19401314af06a73180918af7693ec5204b3f858806e6919af05a1a8c6daea2ee3f08fd2401c082f09f7042a7a0b6b484287050a15f5d8c1011c200d42eb51aff5a484fe1de9feaa264ed3022b6f5b1b54a4a316d3d7e2635210ad83e2d3c497bed46f417ed22804682529b034925dc785a2d74361d1db395d1681d71bc1b0635908ec3e92f850577f35912fe5c173402e6e2ed8003c64a57bb65a2013014a3ce14ccc725f733a50457a696396cac0a551cdda03a2ec81597031feaddd801a9bceaeb5f862a4cdb3eda06dc317a96b29c27d78dd977cc6f25d62bb967814fd1d7e87c675042522c904fadf1cff80289374de8f98df511de975011d877058aa7ea9cdf186ccfa5cfa5258e581ee7ee73e16dcfaa82f079a16c95e6ca4f49c037b2423c12006b549a0b80c5dc9b93685fd2a4bda99e5aea74eafe9d28149e94adc538198d459c7a9a45a1fd24011c69aaa26aa94954fb7dfae2d63eac286b4979e7d513eac8144a7bccc34fb298b7c556a82e7450bf590f1ae658c474ac7c12b3fccaec877b6bdd11fd9655a27b7b69b922b1629a24a8d7f81a6827dd22cbab62d121f5cf96d59be904022360c5bf04d2435c52541ea4d7f932e19fc471a0afb38f2e84668d974c57c963346d790a670a699ff53b5557def1ce9650a8624bb2065f9ce99d6b5361a1b39629040fc897a5d0a07816618840f86601508e90198de64d7091a8bda406009948fa9ebbf2f56adc66e057ab23152504438062867c4237ce2b99c020add16bf66a0c06a072c4fabd9b9fd1146b73366535d934328188798ccc18ad37d30e06a39b8e14468d32820d912359323b7d474dbf507e894a448a4e921f70d1fb4d4ac03b178ae157814004fda0015c202cbc492bde212955236761fcad7de9ec8932946b5352f6c35a242d39b354a1f96a706bcb84174a5b11a7e51cf0adeec9c1e5c88a3f8f32ca81977db668650734398e6feb45cbe0ed5c5d8f77cfa67b78f8c7c856629e6731321126d12ebe70603fe28d4281f5c9165e60749bc8688d7ff3cd509d958dcd1ac7b068a3548af0a3e20b984bf4406a6c6d55c674bc83240757e3a0515bbb626b6b6b863fa7029c5043d67ebcd531601d39b8b6b7e507a176216a264fb80f574d7a6a587d5ebba7c355007630fc49127368f6b672bd12fba956db75f189bd438f5034badc04d396b04a021608a7888434eefffd082efb0c3196698d1b015b42eb6f5a2123c085d37841c0cd6c7a1d77c55d61d76426758cdadcad3d7b1037b5d94c29962d4fe218c9f98c89ecdd9a50a48bc534bc167d536e11906bf594876aea01683269255701b705ac454b250abd45d10f4f8b881c6f4a360014b450132d750099100e15c70ef65ab0eeea340604f3c7b3eb3c51292036a6cc2843989ea418631fb595ca9d7e23a88a3a7d0a8dd5ec1212fc786dcf7107214d5c4d3f2cffffe2b1fbef09816033fdb616b9318b80b32f045ec1bec678dd1500480154ccdd87365d602f0126b57015784abee6fdb9b7f22ba135cb882dfa738baa17233fa53c68d35e4102f185c07a1310b710ff2d63db7238d36487ab504e64e239d0641137cd75cb282e831621f101663efb2f9de2520049c0d08a7c965477fef9d575ba31e101faaea20f96e40046132cd3aa981e8f360cb6a9bac68844d07c7d1741e56b104f634fa1a0c31d64ca4526de4f1754effc40aa04b9dfdb6f53ef77540d66fc9d0a4058fb46518433a1467a71873a03600b47c81e0d452cc44728e3a625235c84f7f62d753faf91fdfa1aea056925168616516e5c4357a7e82c94127fe8a4626c868bb7bcc13a0422e15e31c82935a4e1b9cac496bb456d1318e2e3e704627f1dbe646f5f1590283833193cb03f28e00f5020fbd43c5fdc39fd35498cf4fb7471417e814974331500d8e4ec8af34af39b457d20843d897d0e015456600325ef702c1bdfa7bbb3f2e7a74dcd8fd77beb3df4022320108d0f48a7a9b2a32e1587eb01094e20cb7c002d3de08f481a1d7cdc6b3f9c7c20185bc7ee65a12aa6003a58b83aa90d90baef64c7d324c662ea5138fbb4bd063720f8f9111e20def54a90755537e44fd506737cf1fcc4d0b320bef16eedbd750f8b2002e3af2f477e9d2912e50e4f03e43742c19e6e94c1ccf84ab04f0eff342bfc1020a544d7e745fa08a740418aa6da398bf10870a860427dcdbb857b6b41cec8e1342059eabb84637e61530039918cca60b860ef24c0df9e586b7ee9ce89336d5f1f7a210f0385ce2ad9f83a8534ab0325a42495a9cc4cd4774c9e8910bbf4e7192c2e98001fc3aae0957bc822f2aca9be874e1493a0dbad2ceaf959996060a2bc1781c23520cd178b0cfdcaa92929b9daef0c4dc752225792454327bc351b63765208972820203954fb6c5f5fa020f757c1bac415fec7e1bad3b7c19a93ba5ba53dc12f5ffc6720e7572f4709fdb74cdf2dbbf50a7bec9c10ee302cc2c5dd878bd109d1c87bfe3e20883ea4bb25deffe4bed0029e066230311cd71a4beafa608d43d615652cf9c146204c98a01cfce1f0ab321105b2f2659d375f691e2f6cd9eb821adc512718acdd6120d5f64f7950ad04508b36baeadb52228ed3a1139512125834c1f449b2a9613d67204550d2bb9a9333d567b6c2ab154f1fb4bde51d4b5c50307989ed07100095485720109b0b89090c8a2fb0c51f3f9ca1eab0e36b4d9200396e7958523e57b705d11b209195a60cb9f034fced26b3336b0c49872fa13d56cc410f59463e4f312c93423d20f52bbd6ff9d724752b5ecdb148a47e86c1378111d76e77a2f4434816e325c62920510cf87382f2a4c1417203c4e17511170ac616fca2caa49b521dd721a8183f1120ee7ab5b0de3332ec1fb81c2d5baaf0509eda6de26147b081866c2a9aaa3435882011ce790ec01637ff533a68dfd8fa22733bd7e4fefe18a5796e9bfaf996adf54b20509ea869f679c1ae8b074619487fedad28874cf9c93d01003e9dd0df2f45b66b20380577ba031eb78adb2c71bedc8154056b157ae0d01203c19d7df420a424f71521d04f8ed62e7175b7e70d7f92dafff586e5a60be02901f9f67e97374bcecfe7830020c6be51acd068843922b23415a1c7b39f4846da7584a496483a36612dc295968c204e43611bdc897913427fae390deb145024077c5808238d960f93c62a62f70e6520e2f6ace6ca1e8b6b73dc2fc3c43f3d1e740f1c663e3e0ff9415b2d282e1d377420bf6b145630d553e8c6e40d84626f01964e3d6befc33ac284263ee2c35d76003721a9d895ebe788be0a6988027ae6b33a26bee33016d8f4b64255f320d34deba4900020c8bbe181b5482c9352b9d607eed48dde4a2759d765bc2da53cbc1c03d68da30a20818a0a41e03fcaf75f38766bfa81738f0c8ec2fabed7142e998e7b55e014300120b61bcde758db04bc126bc67a1c87b9fb4f8eb2c3958b9e4106306508e2b22c5220cec0548ab3977bd4bbc802483f0c2ce3b1d6dae43bc0f8c79167e4a76ee6f7522007f74fab71a89f6b94bdc8b5128de93ef2be214d04fbb8ac8c2832e1f2ea6371213c26fe406e69b2060b60c6d0eb7759fe85f19cb2288344239f2a398f04b2b8900020689efa2cc2baa15ce24852b1f71d08200fb1f2ab8e10be0c4db8c8263a85032220b1f2737db4e6e55370ce004319e7c5e137f3567bc4d5603e4ed1f5c636db6a6b206dc3dc994eb2ed126f1a76892117b18b24028dce73ab6d35924ec69df9bc361121fb6e92175d959528d8326cafddb4ee94fb9ffc2d9c8a413898f14cec99d4299a0020349eb2d5c10d69df3bfc41f99d6cafff21ce69323637565e1cefa60173ad0d78201818225053a5e2d51745c5002281935d4dca8ad82ff76c5ca2dee6be84fb767021e8d31f169295ce6052584692ed9e4da87e637ca0b52455f216d5036c55d45c85002100c51c6bcf08c0a3926b2be93b47d0ed8955386d369ab8d6849957efb23904a20020b4b64e35cfcbde2461db3ce57292361aa30d136b0e7bbc766f4aa3ab4a72b66821e802f9eb8168e002b64b2b7ccdaba46e73e5c422dda18311788b19eec7af5782002062e41e97d6c8f4c6ac4c0f978f6f8488c92a51eb5793ad109abee4d6c009256920009f7f1755459578897b027bc2b282466aa1d3b9b0983fe4cdf51c9090548d5120760bc9a32ab7facf7f3c1d1aea1b6cab28bb65276e269472cb24ded949256c0121ea8ed0306eb39aeefdd4f57e98d25cef9ee6288a915cc96207ddff402bc2fe81002138caf88bb834204209e39e6c9430ceda0294f1253f8be81917370a34eb28149200203aa2e1582961cbf9b22333ed51aa4512dddeb3cef2e4fdc3644dfa5145366618203a0ac51cf2e1a98ea343e9ca5fbead275516bc20e9bc421e967be939026677682164c2810712d79c82e75116a7e173af1208478e8ab6763c7ee58551b4f323f78200209943e959b9228e61e5def9b2ea18de1688565a2c49dd98f1c0a7d43e81a6800820c4ab26007fa076e1ecd22f104a860f10b22b1ce2ad1229eaa2cf204815f7246e204090f4ac3d42b2793d83aa79103e0d0488ae1297109a6f47bcb29d8c8b6d383d2058ceed8e9f61bca997b4fef220feba451f5401ff7186d1348aab81d7951b5e1d2014994a638981db8c53c2a364c744009050975bfc23fe7bcfc5c8c36b3df0bc2120e26863332f579b931156f0559025f7f96a4d72a9fd108fbfd5c29a71b13df72520e84680238c100c359efc6bfbb7e97d3ddd4d1d24ef3fe8a5208a714580e9aa552108c2aed76de2cc32cfd4d0fa02eea4be069e74da620ba09461e63b5127cf9d8e0020626acd1bbc9c821f30e5ad5b682272cc4b87a9e05323357a367e170ebdae283c20ff8a87793a1dd9996598d50333ace7856916b2add797fb4cd7cb7fdf24245a642165e43a1aa272317475fddbb370cb547766ee44842ab25d83cef370304213eb8b002121565e2ca6c09a263cfcd17fed20fb6aa06e1e798276a5f0ca8cc16dfc5c309600203e8034276a6f2379f8374e8fbc709a692b2dfdf271c0726c4f890282a40eec2920fa335f7d0543267026ecca4424d15cd07755568dc93c6557f850031098fa55572036cea7284ab1abcbe3c7a83ea779392fec2fd8c1524f95c5bb481c67e300600521444c7d18c458aa1e62e5efc656b27d6396c2fc9299befe67e3eef807711f948c0020829cf5a6319d4467027f9057dfa46e1de952a01233e068f7fd18cdadf9a3534820b622a3b592686cde240a056840080c5116582bf166f4655ace84b34145a78b0520fddc9480245d4ac36034ada99a182ada449373ea0fe16557c3c6d9d55b21942720e59aec0bc8b52c1ea0179ef1bece2bf3275164c8b14ef9528a1335dbe13def6620af95305f745778a84f8405956a82816f366957d31e74af5509ff0b7f7dee737221424e7838cbff036afc7e59ba67c6add560e242a1772ff069a31e5e320c08428e0020f4855f5217b4a20c250b75c69d494ec450321911f9d7841e489d43b3739eca792010f35a5fc740eccaa7fa007f33b028089dbb8bf539f560df19627d5bdba6fe3f213c35de64e6b2d64bdb6ba246a5c9b6a91047e35240db65c383a1be9210b7838c0050fd000184213201216f3c4d446f38e3733348efdc4a4dfd79febf41f03567e0ec2b5a8acb93639951dc506d8649ca6d1926a25b4f19549d2b3700cff07c100c47c456ae23df2e60e27f8822c427e2de12038e00293de92621bc4a27d104803e48d076e3ffcf8c31f549fe9f95c6b9233be396cbd3c557efdfca11fdd91397e52f35d6b3a2130fd46ae505dc66bc987d8e93689d41be09a1df024af243b843e18f298de218ab87e557c782bd20648dc4fd4a5e654f1e9fa59626663adb179f51fec2f5534f2f4929ceaccd34d6928ca3d42fcc5efa32490428abc3296147147eabffda85ec2be06be4512448aabe1829d0219902fe1e9cd2bef29a8f89ed8eb1966b89bffd00015e46cc825850532b9bd9d584441772b3963c554af1424ffa9ccc7d7de55dbef9456a3fbf7b4be985d185eb898e166ba646daa12cca359ea77bb7a59e45e8a6cf2708c16b5ccacb708839eef2ee4b5b6597ba3c5899c9f5214bdace3a521fe36cd39b77952d1bcc81f9e5edfae34f1cce40369b7ee492701351d34e231af8a3768cc158a796e900d0cac462f5204da5cde3abcf561ad60f91fc8951e4fb37166ce37261649f840aa51e6ff9be749d72f1f5b5ae3349f14d572860dcf36aa4655788b8cf5108dff7a4e231d2e2a3bec0a159a1e16c9bb38c4052439f2b6b1a62addf739772a1d51057c89751f7518a3b1cd7b37c4ff7d621a54bca5b3936b137c3fd0001b4a09bfe02e11668f8f6204cb9ffa9817ec412c820b6b394ce2cc3a12546ebc5052ec1c74876f3de8fa22e19158198cd04c1336a792ea47e63c2bf4e85dbe7460501606c97409a906dae4e7ad84517a7955793365ca4f49b5f6829efe61f52069fa19cb30ce0a74d415898e7e134ed8c4106cc9d6be32577e9a024b9dfd8129cb5efb1ef282802fe0066aa41e587ac9ee20d6416010e1b0772a44b6d6d1eb4ddb32b80b288952b26323bbec16614c227e4599cf721484443f56571c7b048e58fe48b1786c44e4979e105196eb9b5803795b8aa3d3e3a8f85e10abeb0f7b43b9a62dbd0905af620309ef74f281f39b241c4b4c109ca7e0ec55b13eef8ee0544c820b833fcfd65b4a897458cb9aa376c6bdccf1032e099598452130bd38d28096322fd0001b6c9b1d3d60a49cdf61f9031694f9f790a4e0118ae39cce789df472c13bba207d101410af89ba4b2a88cff5eb09e53593bfdc69a4e2706633a45c77a6d8f4baff22c7e5e6b32278dbfe97100271b1fd1e64463fa6a757c4cf4b19954d1d363494664750fcde0f0b6e35f9e0f08f4fb1e438669f78b10e800943840b15541a31c3090437364159681974adba314bcac37e6cb3ec97c3d8e0cd52241a4c162787c199a256599b97e488b8a75a46d2c3d8af1951ddac43b0d338e739deed1ba26a8616f04d124244f365573b602697aab0b427a8f26f1a22de77c563cda13fc03a030817a837c497732c6108eac62102641176f62d5b8e73d0e27e17586d6e2c2c3fd0001ca7e381637425fa77d95f92b8b491ea1398f53d763a6ce32a2b2ff972ab74bd377723dde5302c487fcf7eac93b089457761ca341f143b7c5aeb51b33cf103f2f4ce4c282bc5bb8c7290c2a9dcc82fce3dcd9669f660872f602de46e869fccdd99c85c06d1d7bc9af4e93502e4ffc385b9641df8f21a25a14b8e4cf99cca023da529f8689d86418c37dbe3d4116e08069f3d08b9c952f4e2164dada9e62908d57cc68b264df8fcbfe449c21cae576adb8169a97294a6bbd369a58654cd182c8403080b3a5a916c107d7271311a291841f3e35e065d48f6023ec4118a3a88d417507ae86b6c74e6ced02c768e0f879844e22862c357a9fe22574e30c179e2243e62192455f24a5c214eba9746d2a8f6a47625b53e5e62bcecade179907fccb08e4b200218a8694fa7fa94c9c174c8e1525bb14dd946981e66b1c02178cde818615e3b6910021fca3cbe539842d405545a71cdacd7a795b6f6fd1c1095497ee8ee215d5cb8eca002039f76b8f7067cd9cf8bde783c8ea6edee7638113ec6729093749c2cc0c15d13c2091ecd1eb4b171326a6a26d764e72e09160ff2112e577149bc7c24e6c0907324421cdebc7f780b73aef4358c6f7e5c9a78ee95dce81c4c1bbbf570cde3c43ff5ca00021c6955241d2f09d2e90e33601fc139d0603919a1011ea7ee89f0e5d53727c45a3002037be468901ac92d7c3481ad63d364a93e2daa023bbcfaf4f6fa40cc54974de19209178cde568ebc660a7698cdd7f83d5932364eb8ac144e62cdc045a00ccda5201fd0001a3347a2cd4b1f5337653554be5fea273a5bf0292c0fd60908c6e699802079912372435166baf2f71b35ec0e00a714e9f7c10096d0f39a65f66f220057f369ce7c8221dddd00e90604f9de897ebf9f06afd41814b57350ca2d7ff7e08f60c94dc1aa087b7975936832a7050ae4a14b47b1986e70d61223cdc37dfeabc5e4212a869d953450202c9c12db23f8819faad59252173ac11c54020fd7324b4d39673befd2b585437affe9398e7967772d408c6db860ec512f94d02b0cbe9c9d414bb4be4b08c3a420a549efaedaa6f0aed7c74044785b611b2d65abfa33374748adb690a54c8719131e9dda9d46afc9dd09c8abc498d6de657920642d3d47a55f17fd3fd000189cc7f3247c6ede0becf1ec89f5d92142d25a713e037454409ccd146dc18c58319550275149342c690f00b1e060db34bf6ea34473c661ff9f32a40214a4715bbcea59a9ad6ff976e43b325135ca16377be53be39255f9e45ab4b3652d629a30500f9e37671d56952b215628ef42c1e49046476e9a760041003742751b158aa85c4e95f70e267d2631491c60cf09a174816890a2dcc2f8a38f296389c3ea66a6d577301297c242a6b9ca7465d8282f2e72e7673f9e1f1c8f470949c970854134de397c08c06d5d21fc487b2b9529c901c052e838a087f15b802d081e869cbb311efed66ba31c17c73cce1e0fc32d58d35e54bc5524776ee3f45d7b8f81fdad4d621b95f023b2202f5d7c814aa89c27b5e0bf5cd64b444c711c2e1c4b805af4fd58300208214f56ae87b268fd16d6df77efaa715fccd1fb1e6298c1c4f7a4f18d39c2f75fd00013fddc975fb72a66d5a7f3ba6df9c1ff55c7abb5a8f08317c6b346875e850223d87d72d71d9b17c11bdbe6eca34b521fe98dc8d31bef6fcfacec74e5c4a6c5f00ac769363df4720e17c3b687a42f29e8edbc20184dc9274d8546368e3fd2408bb68dd224b73a1487c10f4e29b0d8b9823f7c73db26ed16baa4c5d75b162cb5a97caff3cc8957072902538e0e698fad2e90b471777c5e8d90e5cd313933c2f3b30e49b6cf7ae7dcc09ab2e5e464601f093973d99c0815c3ea587d1803b4ca1d9bcc2ac20cb818f95d9239fbbe62c3356ba41f7dc9d2232f6221447fc4858bbf11ee382bfc639d9101943d958a59ff9b81007508c0879c4bf16885bcd6eb2383898fd00018ef6741f57def1a55a42b565ebe0451f0208762da626acaf0cbaac6d9bcec14e22c15660c2a0c5a23b27c445ad968a7e6b2daab11463040eb50799858b7a5063965f947234beb0d42fe1c2dc7bdce7fcda3de1bae384b62e798995eba4836dbee41a8a4de269c026018a22687ad77985d049bda1d39b79528b320ad3d22d893c547b4dc4acb57fac4e0603468beafdf54408ddb7d4c5db0cf14162d0d735b3fad10888f0048035481e5713b846761838504a36c1f956b072b46f11c7c6fc86c766c36fc7dfd5068855335b96162d8b299e3cd21060fed730310d7748c3d16f228b0eea1f37f5eee5453e3a19ebe1e60e4f2834d3338bf1887969de57ef02a1bbfd0001b50e09d8212707f0035a46513208bdba63a5d1fd7a7ea88e181d7a3de81b8afb4ea4b0428eff67c04b27372d73896fd9df443aa9b2a42cdb8665271bb1c54833454504dbc80645bc02366dc998d334b2723bbf5ee1e139265c107db84774b4884f26b57818d39d05b47d5bdab25778965a0627a96ec303c743ba57c0d32d0337a6d3dbe982284109eb0a7976221816cf3fa7f2e3cf739f30be83f253564af7f411358d9c5340fa5578120c48b617c088d9a3d0811a575bb1e96b746e25312ff6d87b9705c04a10123c7606b4c5d6658244121310ce3f1d062250b57ee51e35e0d7b787d38206fd9d26c70ef94f337cc7108ae8ffddf5fa314e6c4e5e1538fbb0fd000192fa78c12fea8a45902ac86e2878e8327b5f4c2ad9bec7d6167bc5590df49cbddda4d4d652f6f087f2ddc7a2813aa5b33048adc5f900eb4acd983bc86ea7b89f630b647a9ef42ab1bb6484ebc20989603144912ffd80158aff7e7d40a1a76489afb21f5f711ecc3ba613868a7aac4f28f2cd5440dfd064c4874d4e398a4718e10de8f7055e452271f01b27544af7035aed32259796962035d7bc37ef843cdccfacf969a1659e354ec27efda8756b09c0bf855f4fbf7598273154b3f517303b6169bdbe4ede890a90d3b34c917311027afeaca7dcd27158b04886dfd81803594292cdb3dbb77ad3a8df97df63095fd28c275b956cc61faddf770608b898d74ca9fd000138b1c34ee04d01368d9cd8ec4d70b97633951ada508c031470cb4cfb15a62426276c7442e7679f926e93325e287ab9ed87446c144be02e559dd076c9c5e1d6a6a7de45438076b1bd7b9ff5a821070877c1d9626925c1f47838e56bb6982b54fb9e131741bab5ea38aabbe4003538c454980dc3be9a436283edc32a3de0321f114ad32cf34e860ca36f18d476980910e564af57da498cd16d08af8b4d4696a9962adeb298d7af4c8c4ad9cb911d80a2115e833f0856833232f92f94cc9c3a6a14270f4c30ada671bacc9aa35bcd3c945dacbb01f4556ebac4e52adf8790dd49fa799584d227ab95afd2da9e67e5642a90e54fc8b693a07384577d04cc40861cc9fd00012c7977632367de82810aecc22a1a802a1aeb6ac54a4a79d8e61606a6eb72effe1abd74a0a18ef83709355e77cef5666f16d94c0d710f5ae3973bb26d07c0a436aebca4d156d42952cc8cedca94162225b5b4780cf47a6426757758957dcf2f638ead2312e67e140ee8c380949c0885c68b396517ba122d90a0ed184564bbe67bf1d0115c77857fbb29945ea00d8e809c295295494dc8cca091d900837b60b31a79bacfae59fe638ee8a2208942e27f605c452be3a4a43cf21e8e0f78e7cc20ffd2c546a0bbcc404b415e4eae2c7b9c2f9121bc741a6f8c5c28c9df3e0169cd86809f5e235ce17fb625f913f94b75b360f9097b625a5305040c55a4057dc4a68efd0001d594cafd37d880576656265ebb737602d6f3b9d0feb3147d8c1f6e1514ca64e3ee046bea5e6892d8db9c094198fb3f697ae6fc880075bbd461e9aab7425ac3a7fad84071b170739e7815d0c76d2ae7fefb718ec132b32a842c58cde33605488043aad9c5df6e58401994a38dc50779e348cd18582ca74aaadcce6df39b221d4235a88000eb2e3efed6744abd0b68836d56ff5d9ec973be549d52a195195725caf3b8cb683da9b96868d35727bf4b74dcbee838d9c39a33d15609fde1c2b345974a93c030952f52da7ac20b8c26b340fd9c08f56b97ccce21004af28ebe4946a91682f9738085cdd9a53e160346cfcf396cfb59465d114c600d9571634aa3c880fd00016c714ccc93e995491adb6718e9ef69fb2cc36044d9e6b73130606b9c845dfdc56e9518a120237f58fb6f2546bec6f47ea6a11f3d898baa47682fe3f537542fe9f6d3679398064a48a3ef8abc92e89eea84c6d8ca956b3c40e9b82b2a496bb1230f8789fc7b0befc061468049d416aca3fb41d4272304a728322684f9ca6125b91dea97bbbba8c83045b5ce8b3110d429d65998b3aed570ecfedd176e98a91eb3410cd501ba52d176bd2c8d05e94acc8f352b3cbe12adefb82d34e174765ef1197f8a93fd4e03c98967ddd0332733e5aa0ce87f63aff78a2b44d10ac63c1549d9a9c6808b743e6f785173924d736bc0890f2e68c1df72b0fb1632bd4679e87690fd0001e736a64f64d58af8d43f0980d29ba57dccea56ff13040270c926a2703401b83859a94d6cbf4cd62109053c9e6ddd1cc61ac649ebb1243037adec455891642de8f43b57afe96cb63736e3e7d735bbeec8d1dc2c698f37f0bcd85bd84cc6b7e25eab500c03f1c62ec730c24208e2df2830d32842277d9e5c9416a91217cbdad60d6a77a7127b09e7463354266a1130d1f04de9041f0f83d41246766027700fa9a02d10d2b0fcb0bd534d22ed38278ce177bbc1c429c09030f105a67db70011d3eb24754bbb31f8a6a98bde215f635409e4cb8c3d769efdd7f1561976bd29876c8a11a130cbb8e3fdf15fd9ad0329852dffd794f499345c3fee09eee21997a5c8a021b993d7cea74a8935b42df2d55606a8841b1a4a8fc0321701d5de9bce1dd1bee100fd00016e510cf2b787c67956990655ca7ba97aaea25163317e7ebfbcf2681b29b0b821aedb8f9b2e309d37f660ac7169dc8234a7e7e4d01e79164eb75f28d284c52ea3d7edff718aa96db6b0b6781366ab985d202823130ca53c2a8e5186fc18862348952e967b1a3e3636517c3e3c48d8fe5e4ef1d5e230ab584964c888c61393ee3d6e34c50446b86e68ebfd048ac86065f9a9c4bbfc2474027b612dcafbbb0416f12d3d856a96557529d1144a852ade77f50f600ecf3c0e00296576eb49a0b211baaa815cea78e1b7a56a1c698ff58488378722f58fdb1ab8bb0063654a8de8344041fddd04be1b4b2b1944ce9d1ebda667caf9ca00e5c2de892a9063a449edd8c1fd00017140014f19e2474e1cc4b40a5a8033f3ddfd960ef33c7e35432deabd85a5b2c18a27b85477c9e6fbb2d8a5e6c686078009b1f7868768fbb5f569ae3429fd64e49cb3a62f32fada09059982a03a20494bd2fd2caa75a01b3ecdbc32acbbd648905d56cd2aefc714af1c24bd6e8f06b1ddbb85e9882ddf8f0e67c654402bfe2e0d9ba404bb3da2d58305184949ce513b3784c3234b39d25e0c6df741683750cb46d7856e67a0d45f4839b5305f9965808d41cbcfb2ad3ad18cdba6744eab0148dd0c1d5e687b6e76bdb9408766b57297be6fdbcf9d7e3e6918c194ef32bb776b5ab85f3a6a164baf866d93ecc3acaefbac43a9fa267bc623cc136ad712b00d788efd00016d3db1d97d5d429249f5cd6dc35f61d0b1b44f1e433cd5e01d28afa6cd6718fc1432b77e8eb6418b0a4b6fcc6707cd3d5c6661cf57b3b73b78feae7c89e25eff2ec1d465be91a18b2f3bc4311919f410cfafe8ae8a06b4b947f9b6fd8ac17453c2f558dfd67f71829be66250d40c3aad6e3ff52d1c0c455e7a4f646445405cf1ad45ba65392bb622a770ab0f5a57e73d32d98ee49d73ac7dde7dad9b9c8e1da9d1e6aef0f9cd120bc1d2cbc5ad819190b758be385f2b0dd736184382af8aa419ac7d734881ab4728ea927b6655b7d3faa4a1e14caf6d38cc706a940bff9c9021e4b6c3ce516d0257deda35d2ac4e0423f01ac849b19b919b773a58f34a06c6b1fd0001464885fd950d292d5aaa6155164216521d2113d795d9aff389771e8f39ff3d96afb5e2359fc52aa6d6cbc3921a7a21e6f3fab62e748337e2cd212456e2b2f52dcb352a75902ac6fdcce93dcf027138be788aad5e09490bbce637751cfd5bda9ec540d7daa92eed7b27ff1bf66585fbe3b39db3de9dd386ecd7671f2395522c1c9006908afd04fe68d88194cbbc3216377cfc27c4fce55c8e558ebc4943cbb477a1172aa8b344c08d6fb853e64ff0f986b7f3e7cc3b2c3d8b2abbc43e08eff15787bfd6a9a8f98b207d8e2530c0c37a37ffcec2fecbd726b4b845ff48a44c1ec7031e4e663ec0042663ff81b9a7fe7d599695737511a685148fce0c3eb01513bb20ffe083f86d11f3c552efeba225a93eb8ea756f45c2e49a4dd08467b79a14000220fd4b0ae4d4c5a165ab75af08922366e89721ba7ee52751e0b3e64017787b9445fd0001d0a8ddbc130bbf7bcd20bdbc8728e812a45cdbe602dcea5826f753b94e1220cf698c1212464a17ed846b29db82b1cf5373241d4117b07a8b7d279d4e8511d22c47be22291e9ab56454becf533b771e5e602542d07828952d5ef900e2548739d57cbb6254c667bc50a0f97c11b7f3dc1624111f9d32cb0d85ac4106b41cd6d82db7aea1135334220163c190744b6daefa456f69c331facf9083af360db6f2a2c80c423357c8fd1bc28fcfd42db69d733efa9ecdff9df079bde1b73a63ee74e5af5c75b67a4824a72466e17b501f6057a68efc19627d115f19fbcb48c0e0889857f0d9191db5875ad6d336adfbf7f09989b2aecfe868c2efc2cc64af46d07c44b9fd000151620fcd4091a3d3e78e8a1f1b5ac9ae8bdd3760320ed9bea2e1b237110d7747e9894704336b958fc92eb200f06507cb56a12f202a8b098bcec5b7b6941dccc18d2bd968538185dbfdbb6ea61eaee22aa8ad24d73df0adfcfafaec181fda3626479710bc19835a5aa7a3b9afd8166b89f5aee8d52589059eda61f19f6319335dfac7765a9a9e22cce0fb3236eeba6ce250ea0b7cfc4a021ca3c88859f556dc1137349a7ad5a628bc47267ad91ff86174a2fe74e3ab298ae8917d6a57a916f00b16bc0f3584b12b0d63141a20b1ed54c6551c6dfa5647783dd9acc68ed75044faf6745161c1ee4abcd9969ff9e01f14791de7d0c8e44e77a5b249e2da833ad3bbfd000119884821e74482b639ec1f8484eb6199a01d6e0a3606e25527d7a9fdbdabaad9f378a9aab04a153b1003d520d03f25a9e41c82504ad6de9fa6cda30a3ee1128c35f49d469a79b3c190ab0fab92d9977478c7ddabb6a66f291b58756e040892f44ebf6c8d0ba3cdf5d9335c8b05b0d34a8f4e832c54979274f5d4554af2d05aec3d51a3cbe03282c9c104f664fc39863c3a23396e762a5a8b5ba18b3c84f0f49f8b7cb6f627905a4fec65e5ab41e868561dba5cc8bcaa8c201d613eb678342aaba5e5d44f7ad7a58810129aaea2e6bb9850ef022e54a50b18e5fbbb76b93f050c31d279e66cd51a29c42591b18db05e88283e52070e6eeffd8fa447bce2f22eb921b2e3f53f38bb201511b9e1d5bbf22bf9e23be137543e34d81a0b194f373dbaa600fd00010ef2d9c59966c760260612842d16526b4352a5f05de655fd0bf50546be1d893d90f4b8264488467880be2c4d7f3c577e6b68335f1e0764fdb16d6fc44fc5bc2c1660798e6b31bdcafbd33a9edb44e48dc37306abe9ea761cd2077e977a6d5b92aff3cdc33f644f47d90fa9a4b172faf29e264c9d55d27cc4e7b7e5e891adb566d172207406ac400348c386e74716a518cfde36169e4cbd8d3093c8d85b99e54474fb7c7ebec5a3beb18b664b953f4d9037041c45738e87c53d55eb92fe862a78627a8f4f3f8f3ef01102b8df05c9c1d81da85e36bff90943e0dd93efc00edbec664fe16f03c8e79d3e105803c50a606f9812d3717921477e224efc9c38604e932116d048938c5e50af925d722ada7f78de5fb1020ef09d45e001364d0ac87ea4b800fd00015a2d2be1965546e9ffe759719faf9960648c9183812a7db83a24b0ea52bc26e3774ac36fcef82756ad386332d49551a147e87ca68fedb289b965a4088b3de6506378af1f858c1c995313e189684bbd3e484499dd7a26097ee6e89ff1d0b6d4c6c03917c53a95d9fd1b6f9a8e59e1687506a5499adea9c1bbf2f63b14207daebf64c8bd5742bec97f6364560cb3d687f8a1ce12a197e87df8b23eff607c97849672fcd5803b43bc8b6dc2090d7b99299248007a3207621c0b40e6fcecc3f68b2888f5abc842a44e6f019391449a7356f9e2637c77612b342fe61e76b307ffb780f529c6c84a6e6ad8d7553021070499e85542cd288a543bb0d318b7abca62c48721047951dd48307113449db6a3f997999f421c62b4524e5baebffef1ed21a6a1a800fd0001c4594030b3c64f25c491e6a5ab25b959438c11265e0fb2e5b2c58e3d59c86d642aff89f4e5137f08ee4871df4098a5748795d596baeadd2db0b006e02822ae2c5f6a93c5281b82a7d60f8c390a264d293f1769d55a5b1f78f0f65f7694477ca79ec16677993d5b6606298171df85a7a8c9bc74a2438618d2aeb384de706b797758dd418b2b5d59dff089f7d4f7f21a8390a3a707c08ea4a238682670a0d80297ba68febea9c4c5afccc241d9f95aa77366880ccf891174b33d95f462839d2820451611e8453d981a703595e8fc73df0a4963c1baab591b7f3dbbbf62b50dc7bbfeb05ec2ecdb1a63d06ec20c4311451b4129a08b94049263e180411ce7b8b7b120a96a6184af4c9ac706b2fb01d6389d94cc4e3930925a5b2bdac9e96dbfa42a78fd0001cb6e8b1c0da834c4fc0d797fc3521eda3ddacade8a4ecfeb518a2e2e8a234d2f6901a7eb4d117404328c70a5c24f36236e88eb1dd19a7e9cf4ec7582f472da053e283dc193d70bc71867d2a348521a860fccebe0b45958f1b5919cb833d79802640b7665b7ec45135b24108eac8a882121b6e6734dcd327b506a434cea9298e6067c36457858e3c18d88a320ce33cc3861f5329bc1f9a8f4af57caf2c134051b12dbb58e309d7bfe9028c3b9b4179759095fa531cf20bdf18134c517ceddfb38b4d94849c3d7eae9b46c353bc43d2f8345e345f818e328875c1bdcd754b706258779c7c30592d0a4c4b5cb557eaad484d1cef2bd4d98d12e70b8fda33f3a5e8320486c47e0fc0d0e679b413041e800ed28ca862ceec23c1a937954131b960fdb3320a3e4a1a7ded3a425d38b144b090d9fb0aeb9ba631622041a702626583d41142520f6fded7e7b2408e70ac4ab0b4d23bc2571cfb9048b0bc738dfd0d6507549a451fd0001656bbe84db36e12bf3c78c07ba3f561d2c2687aaeeef2e2da17cb00f060e025a993c12551c12bd4d75c4c116d18951515f42c5628b0858d85b8afe5deb0aa14a2e33d147eb62b9a52bd4769b6a97382d6c39e5f70e727ee0c9a4684fcf9a03e7232394b4ecc6a2d32e27fe2b436b1081bcb4f3c8bc844e9cb1cf9b828bfc155a2467d506be2f89c9a36bfe2d19125fb21666ed4625cf881ffba75e67d209fde2742d5a46634019cf1f96c1e649a9dd58edbc374b9d6220bccf20055104e8ae8917537fd07f69e12e0b8200af3d924997ee33a7d1e9eb3ebba741f0edd1b59f05e1f279d0c4f7106276968fac8055088bd5f57840438a2776bb21693d9587d188fd00011ae402863053cdeb79711a4c4e7472a1559d86f89bc4daaa6865eced1126197f3c43d78ba16fb2d6508632ab4712b13c3f45d674064a51867bcba96a71b7683fa69ad337fd0c50e7927b913f025fcc47adddf1376a053280cd0fc45bbf29767b4555bbc4054a824c11dc93c3648b3e1c2ca42a5dc5f536eca084370ef65c5a6229bcac295c3fa578fc22eab793205cdc8d37fe3cbf7dc6e1a7c53ff3c7e4d226f0a0d4667bad282c625695d4a1ad88931ca894d4931b19b09c56d2f72e72f62600c97348ed8814f0841baea716d1e0d90f26f8c3932a1e66dd1e8c8e039d3e891ab7be0a887c16ea9fbf3dd7f2c7200587ef56cd0e75e8e828aedcef6198e6d6fd0001a265b258bad27b42d12a12e43a25bf566f2b77f6f0924e8e0c32f294768fd6d9f92e5a15dcc94a2067c71a1f9740700ee0e7f626d35fad2c441b176a077fe681515cb0c8613b0b43c708895c9ca5a41745ca87cc5e8d02e272a484be75cd9afc7478b98bd4c030c3dd4885c5214efbe70cf9a1f8a2616e2eadc150f979e1ca8c389b6fa288889464886bbdaee7539c6bba71ad0927e455d45db2c7371a5b15ccd8c7e7e91592e9bd057d85a18a9a9d1165a84329a6c7beab031f2819cf36a26aeaa4cbef4e7871c472b9b363a1a5d597005cb98e6828a7b6b7ae81cbd036f8d8afbc6289efacedbe51c10f27462c81525b18d119ab4527d9c6db52cde19cfdddfd000143a024d1136d92c3d0de09ce4a3837fff20ce4c4307fca87b1a09acdba6a1df122cc69dca4ec3ca89118ade730ec8959d0dd84db0eff5ab2ff71793af68a2d6bf3a301edce9cf1088046b3177c18d90f6318e2bea3a071469873d1a320d5036a6ea1375ce17113721f01852e1c436745536bba80365d2ee1060a73098f99983d18511059ecac21b84131d845bddfc589a1a4c195ee1d89ba9845c09d681a87c3fe2322cdf571b4a31756d2de38276b97ecef325f4ada73b747d78899c3f84aeec26fc9732ef5843f8b2d7af0fee0f04e01f85731eb9fde3f0a69c4c0aad09f51db5cd03db3627cce5d2a1dfe9910817efc2e53ebf9f79afaf09521a3f14980cd20d6c0a0d48152ef8efbf7a58b809f5c28186addc9690ba0857cbf2c96e395e92afd0001781c2002615bfb1b7d35b2e208a1df0bd8f95f2d639422c213683828227885660bb231058546848fd01763a1ae99e0a7a1040a0f398d8171b45ffc4a58a4439a5addbad802fe79c71a4a27fea8ed229c51d6cb26c3e21127aeccd2f0e31ff1c7d38987c95b917d4c86439a7a0d54b3985ccc3072727c654bea4d473676a45f13ac693de273581cd6f864fba7ff00f3e61cabfb689689c49849419ca1cf489cf9db2138f65c1445b74a0e0cc83e8368e6e79149e699b6e64c77c70ac5156bfa98794cd0c561732fc7e3623673e1f0ebc09de026d9745c4986d498762be6799cf99143fca5d69d04283b504c8d8e325c8811853d467b72e204417c7e35064ce7bcfd0001e42f7a528e3e1de18bdde6e2d9888886aea8ab8eb149299c32c68f6c93a19889efc05e641037687a091933cc83bfbdfac77d48a2828bac6b0625260d4b9da29b0d215d403c6d3aaaf33addeeac4d46875cbc4e3edc1a865d6fe0b6a588631188897cfd131672a8c5d098c0ff8ec5b17bcaecac1d78f83b5296408c994c1e81f93021da25ca403ab6694fb3e82ed260e88067e3edcd8cd0e70ce4c79b49962096eeb1eeb2b17be69033a338ebfadaaa1293f7fe17fb7ddce051d9eb82c84d639bab4a415353ae82dfed2996a26d184189ff1f25d3196c67f34ae50a39bdc2f711ed6468b364d1f0ea8eb29f486dc6c2f6dc59a24acf4772bd542557e9ef4b5b93fd00015ea90490404b84482136f214f4497c09a0ca87dcebecc03ac3152ae1c3e87b945f721137c7a309e1036d0e5d94c6cce38c36b1645a62c7160ca45abcfb5c165eb696154ae38684002a150ab45f1b8f1bb69b28757e2503967a1432090b6c90ce7e3671860e40e95200f8a1ac1ad927b49bdc0a66472eac7123c383ba28578b149f121ad8b1ead1e1908858a640ebebd2e1b8a4f787e5f41d573168493115448edb0de580a8c281b783afe2b62ac6ea243d021187367df9fd28f97e2ca7c8856d89c64a4c3c5e2147aea8120b4bc8b2d0b8ec5b7edeed35d24a800760e82ab19cb7363c7b2fcde6200b8e63e2b698489e9dc0bc4c8f1ba9ff68b59a7277038eb920fe94cb66b60142227c026662ddbc3dc29b373c5c805c365b245c2f69152f1e2b2007e3634d06ba18b973add33bf3e23a7175729a9442fc4213adcb0e5a52e2cc272096af7547b4220ef6335cf5fe47c75896ef2d27e58acb6d7b444b3645ad1a9370fd0001f3642f6dab0eedbdf04e554106719fde491a9bfe00e8228f250e0035f5a95782bef5680a15e6148467d4c7db9e22d9a4bc766ba884940645845b25ace95d405744929adc2c63e41e4c807e33a0d514919c09e855c9d77690be00720d83dcf2b2276e157d39b7acb3ae262e65a8a09ff49478fcd67765dd03b545d7e83bf194ee6b0c5f83c41f7c470e0a1c3f1014a7afc2b7149c01b3f1181eeee4ce8a9be47f0f7f897a05683629d6164fb882e1b67765e7560e7f5c6be76ad9902a755c6af0c455156b93f14e618f533feb9d351bb956da352b7a9d63cc5082d6f9768d80f1bb703c84be2bd75b848f06925f47e226c424c0ae8ad4293b0e9c330cdb16bbddfd000158cc2778c31d0436228349fd19e0428de5916401bbed1f3668014cc51cc6b42381c32e5a7d5ec4d194037b0544284c52e151b652e2733b17065b2a98fc1f5ff30a8acf5dc7522b64eb94a8a71526e2aff0acba058b541fa412bb5344eaae4945ace66b4f9251e842e4b52feae5ad89ae1ce3617debbb551c09c87c6e6e69b7942f1178db84dd758fcb3f63d658e3f48c1a47b725cd2e003a59b8e318950a45fd908c6c4d53d706dfcc2041c046324e3925c9ca032f62cc825f0c11c9fff3fed99f616837244c7d71b3a8d79a8d5bba3284280b59ca1d0ed044801b7d4d6148f014c8c04e8859d9c3d074a7ad77b86ea0ae39fde03fb33eda53fe5c1728cf2daafd0001991691f21883d17fbf12b24e86f0f639967240dcf120c01713ad612ce32a7b8a9c911248414d8a2b836e82c827270f60f154d5de0efb7aec5b3db3f8b9cedbcda84aedea5fd0988ed0069871fda9db5ec2a1ba0f04592048f0040599023ac01e758886daa3fdd9190bb1c8ea29898cdc711e8e8e6c4497ae95ee2e5a6730bd8388d8fa812b21c9b97af84a7cc9f790139b0005dc86efe16436e63982fe8d8ca6bc5bea86b2297a0726dab7704fdcc8a3f6c273eb0f8aa008ad1e6c4a985d7cefe05d3b24cfd195d2fae5392a48be5bd50386244fa9002f95ce18efe97be356a3c5b3990172f812987d0fa10d7fdf9afcd20e165697e3e8f1fa564d1f03010f8f20d53cbea6789dd88800af410af54c3c346483fa085c6e02c088092372ce828e2afd0001f903226d53becf91fe3ee0e556a7ddd652e179dc3c2de5d7af38f380c9916791a37e149ec620429b47e5e0c016b898ee1dc45db857c93a718daeab3167c3c336b22692da51bbf7ef1bc42cba25af0b1fa89df2adcf41535803ad6e80ff1e1f57c80514f1d091040aec55e87c4810ea31ba22e4f93a101d71e324cfb2d84e381f1a59a601ea97907013e119a24a468b27558b68de170690e71bc3c2d7361ca7f77d116b642516d9cb128a70d6bfba83b2c22420059e8c22c9f794f2e144a3a065c942d3276253013efaa88a80e3d7f3c32dc249347a6df656df62d4f9482cc8470bd1bebda925d47c5cd9a54ce81d7bda23c2d0434a98a1f73911c6172facb08a21cb1b1935a2667416f5cb810c787fac55fe8e14146721452870f27b1d5a14fcce0021600ca51450ca3b29e9ff6b388f4df5286db585d027f68fbbd1d4a95fd756318700fd0001b941fa0b95cbce10d49d29c80ac6c45bb624fbcef94b9bcc75d3593a5e11ed85488b6332f9d059e0123d5df6486dd2f57a9239d137d46f3b9c0120d391d1f06cd48c13a0b847020d0832b15162461811662eadcaa2757e2b6b2240d478e7411c7e807e09ef824ecfb3681b49aa72a3319dd310d8efd930ffa7751a222e73f198032f2dfa00e978c9542dc476ca44b161fe470a5f63759de5086a18fc92aa375c608841c6ea41ebc6fb86dea22a8987f7d9abac948d5e67a173eee3b9b90c323c4f2624f53fcaaadd79427a36f560ce6cbd99872d8119acd2173935b0331217e33ceb4a0ef314e45c1a90ad06a46948a38a00feac8f58d8780e6d15ff6e013dbe214cc25d39b2def7e68e2d5c7afd69c5d629265f750b683dd660040dd3220a2ad600209901a1f845f602ecd4f341e7b68c6787561b4f18d7819c8b319dba6836a42517216578b022ea0911e03a47f0814046cea045fa63bb506730b0b491614417df91f900fd0001ab0bc038363d54f8e9e8ebed6a498b0f989c8ee56c81720bd66f71d0d97477d0db56d4481cc0e57c2316f4bd3a6843f86e5b28152436ba23f377dac267c7bb6501666efefb705be623d0491dd42a1a5397f45b6be6ceb1e0499842914f56296a34f304320f5b623ae7e16379d89394b7057d1b92de4c913265ba231d81dbf9e4b586704803baf1cf0fd474a721bd65206df02888dd77df03c831f8433f3b2c7cf7e1211c7c85a975d129f33734fcf77c09aaf68b681da7c506e8c89ac5394589d185117b722b757e307ccf31e9ddc1b9131633bd684ad458fdef09d346566eb4df920801ec4ac019081feda518cafe1ff9f9197b1473ddb18d3349652db870e0fd00014168b6756041ac27a58e4160cdc78c2cca30e144cfac7c58d40b5521c76ae171399e4e22bd3eb5a59d0a44666cbd8d38c4983f8e2bac6fa5ee84c86adbf9679bf8631dec545667463df5fbfdd5ff2d0f6f9021a4a03510e291253bd133520d5366e3c272bf8ec41a14b15aea97c420ff263bb52dacb3c3962359312e5a4690483434ba5f592057dc449727f03768f3756c24c85814e1204d2d90cad6e656d39e7dd7b9c4bfcd103dac62415b0d64901b01278602553c149f6d64342f16757b5ca1033494395404fe1ac7ae33d796be12b0d4f986de23186ecdbdc8477a742fdc973b34b312f5984b74faa42f5f62dd4938f76f5d7ec141249902825d81ab2d88fd000193a2f7ea915b7e506459b8484c43d305deca93a84b3c313a6cea7286d485395158806c3d7f3155f3ddadbfc913cbd7673193a951b356ae208319ecf406172cfbbad940f9fb6accde0c73ea5a19037d5d8644b4de22db968851a303717f473c491465712da34aba4e5dbc31361163ae1492bb3435779569598639943640b6e14233d8888aefa72580e22091b6848592d5b2273cd2df009d614da03d0c0803b4320800094dcbaaa337fd210820e07a8deabbd595d2b59b3bee5c7a27e585a1cef44e532de83f08f3df43d68f5934ca69776b8ee8ddc42969970d64145c093d4e989cf32ba71f750cd03b5c818c33462d05c6477ec1f0ec81c48e4b119d05d31cd5fd00010b1cbcbad09df7edcebe79a913033895c66686f078402893059e4a986fb76d28e89324f5b04ead2c2aa0a05786faa1e8f66fbaf66b7ca94bf52123e6afe4dab54bc33e5860e8766031dd256edf40b294f55062e613cafac04f77e284f5097e88cd7aaf46fd28f4fc24ef1ec1aa0bebbb3da5b504e4dca8dfc13ed99e4371dea6f3bc0f78cdf6ff47548e5a426c92095da045e5ccf45f446116e2c9567ed01d62c1fd3a78c62aa359118f77705174db6a4faacaa0b6d49b4da38e42b20e9cd2ed7979a0f6da28964e0b3fb755046f0a5477dda7465e00d13c8751c36255b7f922dbfe108a14e359257ad607dd7e1c9ccf330779135714b01746a25ddd11e565c821713c8702e50956f6f6ed9c283cfdd08938c8ccf08ada04309e47fe9ddf5cf5dc00fd0001f5e2772a310c56c1343104f8f618a6408dbdd5e421e31856f271fb33329d5055c4c73e06870feb8f7b298cb9b403dd6c4e87dd2137ac84df583c58ffd627b0c52996bea39da5959ccce7968455d3b4ac512c3d552cf63f2da74d6de8e8f037eca47a715d0d9398502b287e5f1034e536a84839bac21ae9958da81fe54c67165fc650f19679c9a628b74fb40c65257dc5b50f690263f9c1eaf41c3365c0e23f91aa7ade32a352c8b88b40b2d7acabbd085185f7737be6f5a7c342e2c908e8a4f997ec949e4bfd6ca7b3da894fd4581c81141cbfc01afc3eb86bc91c304c18c1dccde49d8b61e41d922fcb02162e444dd4931a076bb072ee3a6d5cebbe8683e992fd0001197c654987867c3fa8c71ce3718a70756063be0179e55bf302daafbbdfdd5669ec38e140e3204b260b1d54373f84657101147e672120d735c179fe2dc5fdf7dbb8aadee63b867e31d9d1242e45bd23ed510dc7a6d44ea7806159895a2293c0898bfa6d0018d9864f584a59003a8d4e3a38eae3ec7c9e35d1e5985acd544547ccc1e31024f05bfd5dc07ccbf535b2e0a0a703548e355eb9d1acd8a59684ba0fb674075011c7be7cc899c30ea6c1df0dc34c9366f47743aa12991192cc836db7fd5a3b0306d6905a82e13b729038c70fd3bea01def02b93cd2e71439a9fdd5fb93da6a72436ce9f5b431b7e18c76d8173964d5a02e24195e761d888a6a2532d785fd000177fcd4e94194a3c34efea9f8f04faccbdeae9f0eb8662b0195db08e2b41a08fed52ab7060465ecebdb2eeed7a768c4f65ed7b5193151a56c752cdf97cd4c6be8daf95c16c6703ec95b6cb541506c715983be6384031685d48190e611ddbded6fa377affb759a1f289523e56761857afb3c33b68f743c59a6bf2098a2f5bf3c1c8a3d41224dd12ef247874ff5ab6257cc3e29a65a1c16f3ecc26108142d1899a340be3db55556258a348f51ef61e8e73fa9db743f08df3d31919f2d9484603f0281422c2cfbd66959dc138c2176d6e242a5a04d5b1cbd8c39ed051486869f8b0f3ecdaf42498be6016dc9d49ae52220612a32f50259fb9a8be0cab88deafd56b12024a02c37de6161be5eee8cf63c3b0d16d0bdca07ad59fa0532678b864c13bc20c87f061046d5218e768801c69818dadd24e62aa4625d5a2af09b08f9eba8efb5e29fc2977184990fcd81a0a3f896691051da425d7b99292d8ad7a3bbae5ed9503c5b761907097dd6457a5444b9c85ccc829d5901d46cd917ead52a3316f96588a7b508501be9f69c73d97e0db9d615f20e50712f377663f6c47f42d005db72b225048abeb8d6e3a60afbe21cd13d4539b7d39806952d745a7c49a552175135d840a3f9b9e9ef6ba3c46e9f4bd42f9bbde0d5d5abda8cf06538d3492aa856d8708285c0aa99567adaf6e0d4b28feec0a55e49879ac8ee966e4520e5eeae3e6b4473cb710b4213973850ea6ff0331b04ccffab2c2c3f55ff28b8bdf644e4c19982916d01e28d745302b079066c61fc0e8e1c90931f122893f7f5eb86e9111f98cf36e719129668dcc4718c52d0c4c6a1a941b939e5e744d61aa9fb1aed6eca5fe062cd109b15abfc5851ff77c026e9ba7023b298d40acb1ae9884671ff1776aedabd859f2a481eb18b963afae2e2ec41e3f3671e9dfaeab234d6aaf97249f4702e706437d8e36f517b3b227bae68b79064a3fbe999bc2d75912d970826908dc17b815ec0f030e20b24f17c527557e36695abd05f67c60475a0c74aff42b5e7fd13efc3b1c3bf5ae6e3251731ffaad110c4210c9d6d78d69d6f68cc31ca99fe783a12fca506af01e28234f01e1b30dcdccdfbe696bd5703ddbf0554c8c4863edb0f252e2a9daa54d4900d8fd6aef61b8a8f1e165eea79a3f20663b08722762f2c548e0cd10cf0d2d8e8b9239b638d02c49749d55b721a6c81d2554b51b49191518150664b7e4dbde3ae02b36ab3ec9250290c8cee6b7371e0d3c7ad64d267cf463c4c82cc5a325aa72461345c4f45c8753de23bc148527b0e982511bed9e4bcd4cc37e1e3b5089373fcbfbef9a111c96cc79e9f37d619c0d68598541f2a37ea0fd614ca310c915c0d412d2eeee8f7d71e4a250bcc60d68ab3f296a5f75d75d627e5913aedf3646f733701218878049c420ce0089ded11b087f16111e397d0f2e354f1df0a858aaf07eeb8e80002210305cf1786fa950ae9b553390d6d62e2b285ebaeb978822439e0922403f9cc7dbc473045022100b807fa7bc196a7b2d7a3000e5e1870e2ff488bfd6e2850aeaefb3c606f28379e022009c3cec446550e5cb04483404a677c4b8406d85c62cb4d714e5ca3a50aa02f2600e80300000100e87648170000001976a914dbe6d470fa9fe4d037043533eff4f80aeef0c8d288ac0000000018b6dcbedb0528a2ac4f32b9ab011220000000000000000000000000000000000000000000000000000000000000000018ffffffff0f228aab01c2028655e8030000dc8ce67bfe1851477371a9ac40b6ae0cb8571f6e2d5285855288f6079f1ce7239ee8c85f465b1820058b79554f41af297e9caf95ce0084b7c35dea0b95e15a2fb9f8e62c5427c1c36120cbc1fc11ff344909079335209c6b84b45a9211cac960f64e9432ba5eb6e4ecb2068223dfe3d85b345da17bf374f9140c9577c148bcc431c9ec3c7d13bd2363dba821381ed9fa0614416261e88330b3e74c40e6561310eab3f26f092e72f3cab761f373d02680dfb52937bd9515be242f6573754f8f665523cce3bd606c8ad190954f8181577fd0efe7cc64b711d03774958df4a5211e44870302056557777951d7ff8c002161a6a59e979f05469cb31770bd484be6525625359979220eb7e9912e835065fb00fd000216aefbae3525166510814d1636b76b0d48ea3cd54a3a17b136a84340989d75f74ff952966830e4c0d59daa006d5a7190978270ee9475a0778afaf002cdce7efdbfad630f72838b5c4a3b538ba61b94bbd9e353437a50725af5f16fbcbf36bb34e7da54e5c24dfc90b545f95c973877bfadc2703ee10585a1fc1c97d7377bf41c9cbcd5a313849a3c826e7c1301083694e6dc05f46899a901ab4a8d7f6b3600df280157fbef6eca4c28fc610957a42a9acf7c4d7f9846ab6b9b04fa6abb5fefc168d45f10078b97d4d6a39638588a1c19e1bfc472657861a902c2d52cd32fb0463746f649ae88bc0602dbf35816fbccf91dc249be809160cbc7f8b6702d6cc5b81fdebd283231f40758afc899f6fecedc51dc4e5d09cb8961092220541f75ddad45680ea92b4ee78c29f58c197a68420bbb25b450c72d02d7249f7facf9927378620eb36fbf9b4ccbcb55627eb9cf905b4a4c65fcb77a537f642f10901b6e94afa37e4afb0d6d91194454a9c2dd8ef8fe4316f8594c7822a7d58cab09657cf501da5be5a44f947bb957b71e4291a7fc60cd5cef9f0676f7c89123c7ff1ae2e6dc001b6f19785534e207fed2bade8597541b13714284f67d6986bc616ef1b0adbe415242fee85acbf482a6a48b3f142ef7ddb5dd1c97a4b0c53c6ac7aceb8c042d9c9ada1bc986b8c276d07fbf8512a3dae6a357fc02b167eb85000040e8693e45fd000200e23abd27b258f9827ed58545a507bd465e255e1156610da314bf7df68b6b55129df84c7b19e362751ebb9beba10790c9c26c5ddc7f087258d81b006c0d2e92be0178bf5edf6e78e89f73cd97746afbb2551dbc97eafe32ae62e7f9ebcd14ad69faf74d2011d16f2c50775f4f499c87c3c50d9d5d486394c2a7f462675d2a4885493332e0610a78fc0c8b08eda42e4bfe93b8c7f80a911a7992a1deb7cca2e40933e1559815688d4e5ae5e58d706bc513e5108449a8393928b5b77ae73cb03fe212c6375b6c5e61fce9db16360a147e7f7fcd49e05a99711d4d5799be77f7e39d9d1397388d6680d4931b48798ce013256a586781ba80168bae63bed4d150b64a73f7d0ab0c9ebb42f5d4db40eeee303783249af4bbf334c660f8c084ed9a2e5fff8be230940a4a08b59418676ef005192365e4e67757288791ce4992b903a31537596cf6dad0be2af2418a6b9cc2c33e99d874168f6a29df189a869b16eb5d24400ab30e4eca9274114d646aaaa8bad45832b6c0ded2bfa698939a8af9d0af380d2afc58966afe0f45483ecad0f114b904cc2fafcf470dd4fb8f193795e8afc3243b4c946d5eac82babac6feaf4ff10bf53acdf8347fb9fb7a5ac4efcf160f0a7ef3576f439404a3078ea092f46f408a955c965344023d847fad7374cf145cadbc8348eeb2c5aa999ebeeb8a5548bb14e0092b184caee354020c19d66cb213fd0002729bdb186c4c494e17fd6effe29cbe7fbc539caca738d54f9ffc6e52a35a27da134192e2f7f4ee2a86af281b78670e662677e97a1ef008f10f42349fa83bf7841d88b1a457d38164a383ae9c6b974137d58216f22d135d30b9d6e7a74952a7e905f385141f4df088415d704cc3b03b2cd600cb5507f8ea1b53fc0e73031a3946c0d6269f020c9c26a3be3bfdcd37f8d3b9fd42538ebd72029fa0bd8eb57a4fe6769e1b43b5d5d7be311e12e52ee9dad67aa988dedc80ad616d7540381993d9de91a7fac6d08e4414254b9d1d72940fec032833a6b1a5605f4b62c47a86d70dbec5ec913d0a613d438cad385fedf24566bf79edd17238e55421520b95772224623f145100e663b2ba20161784f688afcf07a900ade1d48060d21be9ba9297697891c2584cb99a44868efbdf65178592ecfbadc92f4883662d6b21b7f266eb21815c7401b8e7da061e3258dd685f8cf65f2c2e407c913f85d053b05f6f92ed1299186632ddcf175ccbbc933044bdac5e10916917dea1146f77a8ba4b4fc8ce260b5deed395ecae9b81baa6b385fecca5d2982041c131ce02a1dec517ad2d459434aa3a514e7a4c6c1362401b1ab62b4c89bd7705d5072e0be5250c60c2fdd946bc73050d3b8bcfaa73165eee3660063f279e824d1e15f87307a40bc9e1ccc0f7d7087ba84fe9275742455241b61d3687d23eb9d7a9cc18072ed8be1492db46a454464090750eb0a393499a73d23f4c552ec6ae425a77f97d4285a7f287066b2198bfaa99073da6f4009755e59838e48cd5fe692962b87da3ea7e14b34b352fb2a4673eaaaa594a094610bd0cc566acafc21891b7b0c2470bbb338f579231e01c064f275c5ac9c2748cc50e7f2e36f2768d2a59c22d14b6b9a431f7772e716731c55ccbf086187fb15bd07a5af3040d468d4088ba9e6383f1df6dead9384758f2da81ee96370d9055ce5a0db56bbdccb57ab490f42a01e083b61c5157b3c00e2011dda865c7294cdfd2be5c03a1a36a4deda9cf03b500fd0101b3c97046a717a2f38fe2265185f4411cc68cce6cf9885a7a8fe6292eb9e3eca69fb6249774a8c82b888d22d5fcdd549846c3ebcf054c6bf07aa4d6b1c0d4d9bd8e16501f65373ceda249e9c760848fc86ea92fae7142d211c8bb4a287c91eb08cef7678ef1f445f76f81d464eb1d29ce5c6d6286d73d49cd0ef03b65376eb146a3ff69a487ec90b53c11cce613a586f22cd56fd34df6f7ad64fa3c68a6ae5c9dad99d4a3d2b0974ea1f627be4f0153c7b5fe472d0c562556c4d8d1c7c592bea45bb7886b74f8639f9487b2f6aebf4848b5718f2ce65b8f5a4efcf140e2857bc9c503f0058f9b6af16e75f2d530fbba8979f81569e6cc0bc04a54e30de4e03a9300fd00019b881d73a78b86771d41c0c2a9ebdb3899727f33d2d4d81dd27b6a00b4c7db265999b8442800750abde7cd97be0c691ed06b5d40da115546d90d4803e82c61d43eb5e2bc684adcf180be47660870921fdedb2ce43f33564541fc0debe175c6c49bdfb51378902dc709594b9b7d34d0af70b67c3c608aeb5185e78c1c39cc3080b71a36115f623a07a4e1a3e1f3e17b6f9f695f1a1acd9dd1319d0a0d67b337e64f720e5168c09196244bc71b083f302e042be19b6aa1f8ad61755f4883c3a1ae615252b884ca3cca5e18a023ad6725f08f9e0ffd60e7a73ccd29afc910d60dc99c06f5953c3e398ef615fca45f6a83f8a0be653d31a7e1a1666cc7334d9cad51fd00016f7efa38c8f9f478a6ea1217d8be9b7f0b01c5d1fa483c26e403eb3a4875aeebbd0e7eb8aab8472a5bd80e8be38df13526042952f813b71f8aeb4a2281bfe5e5d9ba70f7e4c9f477706da922899f505dd172e260ce5f008b59c0590d498ac50810e9a38d35f1a2ea4e9ce8f77e46d0b5604dbf94629cfb8b65453a0295eeca9d992365309b7956481a8c5080510a09183bd3358fb26933e15c83fe2ca6d186e631d72889a09464f5dafdc8a93dd100329071e52beb522bef1af0fbae0516ad3b02011e19a2b2924791b3f22679b78039c8356c0e6676e2451487f056d0cff064f55a992afba08f59af7606a809394772ff85c4c40673dd54eb30020c6a5cde3cfd0001ecbc47845e4a1f3c05c4e9d0a47e5e8996326f48d7ee1bd0e432c5f33fecf8a94feaf03f2da65525bbcb119c7928456c28f31c183a21af1acfcd9615669cf47a077f861f694bfc1831f1a71ab66540906c62b85274f63abcc53a37e3fccdc8563ca2153818b6b796473848d765d1c81d4e6f78ef8fce804184e04664c5c6af7c3abe4d92aac3f6ea99b84a3e53a7bf7679c26dc96804ac9e1443b054e3c55fe89315106a14646d06b84122861ac0ac27fe64fbee3c9807b0264a90eb9602187f4df2cc0c62b025ba70bbb30a7e43d533f32546d2e6f97537bc2d623a7f545676a37cb511614037d77fe21a35ce4a2275e7ea1d85b7bc19e477c61033f5effe7fa5d8ff48e2873845157b7a29718e6692704a9d864301fa254798555172c6277cce4ed5aebdff9c27a1bb37071677c16da15d6c1547c5b708e1988eba68befff1f1743342ad7cb89ec8731b567198953d965ef6e395d38b410a326ef3e262937c763179f22a076d17f848dc4e36be38d838d5788f121d771f23209a0d50fb3d016c5cbc47cec31069f7d22bd78691662d8b609932b9533bf828e213e5ed4e61f2b80f05acbc00fda0017bdc59c8a6a23d261cdf1dd10e91523503c3375a803755c29b6a84bac626708b7db937ed39f4053b5c6376b3988fc9388cc64dc07466c67704a32b703d5dd86d8fb8597cc2ff7ca190044c66638028855f29fdf2a0943c64125e3ad2487de6928bc34088957eb32d843613e6b588a91cbfd8c37c24ba655739cefdb203bc3061ff93498160c7e949823b0947c68b6c51dcdf038c538f50413266680ee23817ed9cb840e0094f6fd2277012aa2c6f82b086242e6332a4e00bb9c12c153dfea9340e681e63d72551f8830b2bb2587e5937685252928894bf90bfa174f62bf7ccd43415a094bb4142fcfe639c62f6eda0e4d7ee033f49f51eaa6a35b0f7f400992d8367a275e9d018a547430083c41a35ef543bb92e159efad39c5b84d127bc68dd581c308afd5f81654ffcdb3317dd6e21d5251916214e873c83b6ac197dbac6c1b93d79b9dd5da090be204b9765fdcf662c9296295610e42a0570a1503dd67c44e17936946f3a6ce61f82b13cf00a0b47d06f2cd28651b6af32ffc58304593d5ce81159ccc952ed980f93182fb468ebccadae4dab4565d64a5bcb3aec7a09d681fd24016a4905a3a143e4d61b16898fddbb0f9d7602ce865716b62ae4f9a37e2f6ab89de930e066db6f4a8667ccc4e79e7ac760642c33e5f24266540c9b4fbadda4c0aa1b6a74d02bdf2324ebf9598d8ba918438de1e3343be0b057925bdd52304581ef621fd085dc55cf8b45d605cb0b60047bfa935c2968d554753a615e75f24086b4e40508ecdeb411ed26c007a1110f3e73f504d7fdefb275cbb59cf9cd68bf4784b8845467fac90275f3bbcc2c14a87fbbd7d111441d6ba0833b9045db43975317aee170242b291f8b07254d395472bd4b67db7576bcf2460bf0c182f745a6cbbec3f680b7c6e0a85308bc3af8af3302355757a77a2fe3f98350e4ea1b3074e37a638c630d529141843583ba4b802e089e0a7ecaeeeb42079e072e64fa5251782cbc67ba9d46e4c7f502d44a06e5212d09f4dc5bef1f1dfc376d4a042f608b860971c44caeb3735cd57e19401314af06a73180918af7693ec5204b3f858806e6919af05a1a8c6daea2ee3f08fd2401c082f09f7042a7a0b6b484287050a15f5d8c1011c200d42eb51aff5a484fe1de9feaa264ed3022b6f5b1b54a4a316d3d7e2635210ad83e2d3c497bed46f417ed22804682529b034925dc785a2d74361d1db395d1681d71bc1b0635908ec3e92f850577f35912fe5c173402e6e2ed8003c64a57bb65a2013014a3ce14ccc725f733a50457a696396cac0a551cdda03a2ec81597031feaddd801a9bceaeb5f862a4cdb3eda06dc317a96b29c27d78dd977cc6f25d62bb967814fd1d7e87c675042522c904fadf1cff80289374de8f98df511de975011d877058aa7ea9cdf186ccfa5cfa5258e581ee7ee73e16dcfaa82f079a16c95e6ca4f49c037b2423c12006b549a0b80c5dc9b93685fd2a4bda99e5aea74eafe9d28149e94adc538198d459c7a9a45a1fd24011c69aaa26aa94954fb7dfae2d63eac286b4979e7d513eac8144a7bccc34fb298b7c556a82e7450bf590f1ae658c474ac7c12b3fccaec877b6bdd11fd9655a27b7b69b922b1629a24a8d7f81a6827dd22cbab62d121f5cf96d59be904022360c5bf04d2435c52541ea4d7f932e19fc471a0afb38f2e84668d974c57c963346d790a670a699ff53b5557def1ce9650a8624bb2065f9ce99d6b5361a1b39629040fc897a5d0a07816618840f86601508e90198de64d7091a8bda406009948fa9ebbf2f56adc66e057ab23152504438062867c4237ce2b99c020add16bf66a0c06a072c4fabd9b9fd1146b73366535d934328188798ccc18ad37d30e06a39b8e14468d32820d912359323b7d474dbf507e894a448a4e921f70d1fb4d4ac03b178ae157814004fda0015c202cbc492bde212955236761fcad7de9ec8932946b5352f6c35a242d39b354a1f96a706bcb84174a5b11a7e51cf0adeec9c1e5c88a3f8f32ca81977db668650734398e6feb45cbe0ed5c5d8f77cfa67b78f8c7c856629e6731321126d12ebe70603fe28d4281f5c9165e60749bc8688d7ff3cd509d958dcd1ac7b068a3548af0a3e20b984bf4406a6c6d55c674bc83240757e3a0515bbb626b6b6b863fa7029c5043d67ebcd531601d39b8b6b7e507a176216a264fb80f574d7a6a587d5ebba7c355007630fc49127368f6b672bd12fba956db75f189bd438f5034badc04d396b04a021608a7888434eefffd082efb0c3196698d1b015b42eb6f5a2123c085d37841c0cd6c7a1d77c55d61d76426758cdadcad3d7b1037b5d94c29962d4fe218c9f98c89ecdd9a50a48bc534bc167d536e11906bf594876aea01683269255701b705ac454b250abd45d10f4f8b881c6f4a360014b450132d750099100e15c70ef65ab0eeea340604f3c7b3eb3c51292036a6cc2843989ea418631fb595ca9d7e23a88a3a7d0a8dd5ec1212fc786dcf7107214d5c4d3f2cffffe2b1fbef09816033fdb616b9318b80b32f045ec1bec678dd1500480154ccdd87365d602f0126b57015784abee6fdb9b7f22ba135cb882dfa738baa17233fa53c68d35e4102f185c07a1310b710ff2d63db7238d36487ab504e64e239d0641137cd75cb282e831621f101663efb2f9de2520049c0d08a7c965477fef9d575ba31e101faaea20f96e40046132cd3aa981e8f360cb6a9bac68844d07c7d1741e56b104f634fa1a0c31d64ca4526de4f1754effc40aa04b9dfdb6f53ef77540d66fc9d0a4058fb46518433a1467a71873a03600b47c81e0d452cc44728e3a625235c84f7f62d753faf91fdfa1aea056925168616516e5c4357a7e82c94127fe8a4626c868bb7bcc13a0422e15e31c82935a4e1b9cac496bb456d1318e2e3e704627f1dbe646f5f1590283833193cb03f28e00f5020fbd43c5fdc39fd35498cf4fb7471417e814974331500d8e4ec8af34af39b457d20843d897d0e015456600325ef702c1bdfa7bbb3f2e7a74dcd8fd77beb3df4022320108d0f48a7a9b2a32e1587eb01094e20cb7c002d3de08f481a1d7cdc6b3f9c7c20185bc7ee65a12aa6003a58b83aa90d90baef64c7d324c662ea5138fbb4bd063720f8f9111e20def54a90755537e44fd506737cf1fcc4d0b320bef16eedbd750f8b2002e3af2f477e9d2912e50e4f03e43742c19e6e94c1ccf84ab04f0eff342bfc1020a544d7e745fa08a740418aa6da398bf10870a860427dcdbb857b6b41cec8e1342059eabb84637e61530039918cca60b860ef24c0df9e586b7ee9ce89336d5f1f7a210f0385ce2ad9f83a8534ab0325a42495a9cc4cd4774c9e8910bbf4e7192c2e98001fc3aae0957bc822f2aca9be874e1493a0dbad2ceaf959996060a2bc1781c23520cd178b0cfdcaa92929b9daef0c4dc752225792454327bc351b63765208972820203954fb6c5f5fa020f757c1bac415fec7e1bad3b7c19a93ba5ba53dc12f5ffc6720e7572f4709fdb74cdf2dbbf50a7bec9c10ee302cc2c5dd878bd109d1c87bfe3e20883ea4bb25deffe4bed0029e066230311cd71a4beafa608d43d615652cf9c146204c98a01cfce1f0ab321105b2f2659d375f691e2f6cd9eb821adc512718acdd6120d5f64f7950ad04508b36baeadb52228ed3a1139512125834c1f449b2a9613d67204550d2bb9a9333d567b6c2ab154f1fb4bde51d4b5c50307989ed07100095485720109b0b89090c8a2fb0c51f3f9ca1eab0e36b4d9200396e7958523e57b705d11b209195a60cb9f034fced26b3336b0c49872fa13d56cc410f59463e4f312c93423d20f52bbd6ff9d724752b5ecdb148a47e86c1378111d76e77a2f4434816e325c62920510cf87382f2a4c1417203c4e17511170ac616fca2caa49b521dd721a8183f1120ee7ab5b0de3332ec1fb81c2d5baaf0509eda6de26147b081866c2a9aaa3435882011ce790ec01637ff533a68dfd8fa22733bd7e4fefe18a5796e9bfaf996adf54b20509ea869f679c1ae8b074619487fedad28874cf9c93d01003e9dd0df2f45b66b20380577ba031eb78adb2c71bedc8154056b157ae0d01203c19d7df420a424f71521d04f8ed62e7175b7e70d7f92dafff586e5a60be02901f9f67e97374bcecfe7830020c6be51acd068843922b23415a1c7b39f4846da7584a496483a36612dc295968c204e43611bdc897913427fae390deb145024077c5808238d960f93c62a62f70e6520e2f6ace6ca1e8b6b73dc2fc3c43f3d1e740f1c663e3e0ff9415b2d282e1d377420bf6b145630d553e8c6e40d84626f01964e3d6befc33ac284263ee2c35d76003721a9d895ebe788be0a6988027ae6b33a26bee33016d8f4b64255f320d34deba4900020c8bbe181b5482c9352b9d607eed48dde4a2759d765bc2da53cbc1c03d68da30a20818a0a41e03fcaf75f38766bfa81738f0c8ec2fabed7142e998e7b55e014300120b61bcde758db04bc126bc67a1c87b9fb4f8eb2c3958b9e4106306508e2b22c5220cec0548ab3977bd4bbc802483f0c2ce3b1d6dae43bc0f8c79167e4a76ee6f7522007f74fab71a89f6b94bdc8b5128de93ef2be214d04fbb8ac8c2832e1f2ea6371213c26fe406e69b2060b60c6d0eb7759fe85f19cb2288344239f2a398f04b2b8900020689efa2cc2baa15ce24852b1f71d08200fb1f2ab8e10be0c4db8c8263a85032220b1f2737db4e6e55370ce004319e7c5e137f3567bc4d5603e4ed1f5c636db6a6b206dc3dc994eb2ed126f1a76892117b18b24028dce73ab6d35924ec69df9bc361121fb6e92175d959528d8326cafddb4ee94fb9ffc2d9c8a413898f14cec99d4299a0020349eb2d5c10d69df3bfc41f99d6cafff21ce69323637565e1cefa60173ad0d78201818225053a5e2d51745c5002281935d4dca8ad82ff76c5ca2dee6be84fb767021e8d31f169295ce6052584692ed9e4da87e637ca0b52455f216d5036c55d45c85002100c51c6bcf08c0a3926b2be93b47d0ed8955386d369ab8d6849957efb23904a20020b4b64e35cfcbde2461db3ce57292361aa30d136b0e7bbc766f4aa3ab4a72b66821e802f9eb8168e002b64b2b7ccdaba46e73e5c422dda18311788b19eec7af5782002062e41e97d6c8f4c6ac4c0f978f6f8488c92a51eb5793ad109abee4d6c009256920009f7f1755459578897b027bc2b282466aa1d3b9b0983fe4cdf51c9090548d5120760bc9a32ab7facf7f3c1d1aea1b6cab28bb65276e269472cb24ded949256c0121ea8ed0306eb39aeefdd4f57e98d25cef9ee6288a915cc96207ddff402bc2fe81002138caf88bb834204209e39e6c9430ceda0294f1253f8be81917370a34eb28149200203aa2e1582961cbf9b22333ed51aa4512dddeb3cef2e4fdc3644dfa5145366618203a0ac51cf2e1a98ea343e9ca5fbead275516bc20e9bc421e967be939026677682164c2810712d79c82e75116a7e173af1208478e8ab6763c7ee58551b4f323f78200209943e959b9228e61e5def9b2ea18de1688565a2c49dd98f1c0a7d43e81a6800820c4ab26007fa076e1ecd22f104a860f10b22b1ce2ad1229eaa2cf204815f7246e204090f4ac3d42b2793d83aa79103e0d0488ae1297109a6f47bcb29d8c8b6d383d2058ceed8e9f61bca997b4fef220feba451f5401ff7186d1348aab81d7951b5e1d2014994a638981db8c53c2a364c744009050975bfc23fe7bcfc5c8c36b3df0bc2120e26863332f579b931156f0559025f7f96a4d72a9fd108fbfd5c29a71b13df72520e84680238c100c359efc6bfbb7e97d3ddd4d1d24ef3fe8a5208a714580e9aa552108c2aed76de2cc32cfd4d0fa02eea4be069e74da620ba09461e63b5127cf9d8e0020626acd1bbc9c821f30e5ad5b682272cc4b87a9e05323357a367e170ebdae283c20ff8a87793a1dd9996598d50333ace7856916b2add797fb4cd7cb7fdf24245a642165e43a1aa272317475fddbb370cb547766ee44842ab25d83cef370304213eb8b002121565e2ca6c09a263cfcd17fed20fb6aa06e1e798276a5f0ca8cc16dfc5c309600203e8034276a6f2379f8374e8fbc709a692b2dfdf271c0726c4f890282a40eec2920fa335f7d0543267026ecca4424d15cd07755568dc93c6557f850031098fa55572036cea7284ab1abcbe3c7a83ea779392fec2fd8c1524f95c5bb481c67e300600521444c7d18c458aa1e62e5efc656b27d6396c2fc9299befe67e3eef807711f948c0020829cf5a6319d4467027f9057dfa46e1de952a01233e068f7fd18cdadf9a3534820b622a3b592686cde240a056840080c5116582bf166f4655ace84b34145a78b0520fddc9480245d4ac36034ada99a182ada449373ea0fe16557c3c6d9d55b21942720e59aec0bc8b52c1ea0179ef1bece2bf3275164c8b14ef9528a1335dbe13def6620af95305f745778a84f8405956a82816f366957d31e74af5509ff0b7f7dee737221424e7838cbff036afc7e59ba67c6add560e242a1772ff069a31e5e320c08428e0020f4855f5217b4a20c250b75c69d494ec450321911f9d7841e489d43b3739eca792010f35a5fc740eccaa7fa007f33b028089dbb8bf539f560df19627d5bdba6fe3f213c35de64e6b2d64bdb6ba246a5c9b6a91047e35240db65c383a1be9210b7838c0050fd000184213201216f3c4d446f38e3733348efdc4a4dfd79febf41f03567e0ec2b5a8acb93639951dc506d8649ca6d1926a25b4f19549d2b3700cff07c100c47c456ae23df2e60e27f8822c427e2de12038e00293de92621bc4a27d104803e48d076e3ffcf8c31f549fe9f95c6b9233be396cbd3c557efdfca11fdd91397e52f35d6b3a2130fd46ae505dc66bc987d8e93689d41be09a1df024af243b843e18f298de218ab87e557c782bd20648dc4fd4a5e654f1e9fa59626663adb179f51fec2f5534f2f4929ceaccd34d6928ca3d42fcc5efa32490428abc3296147147eabffda85ec2be06be4512448aabe1829d0219902fe1e9cd2bef29a8f89ed8eb1966b89bffd00015e46cc825850532b9bd9d584441772b3963c554af1424ffa9ccc7d7de55dbef9456a3fbf7b4be985d185eb898e166ba646daa12cca359ea77bb7a59e45e8a6cf2708c16b5ccacb708839eef2ee4b5b6597ba3c5899c9f5214bdace3a521fe36cd39b77952d1bcc81f9e5edfae34f1cce40369b7ee492701351d34e231af8a3768cc158a796e900d0cac462f5204da5cde3abcf561ad60f91fc8951e4fb37166ce37261649f840aa51e6ff9be749d72f1f5b5ae3349f14d572860dcf36aa4655788b8cf5108dff7a4e231d2e2a3bec0a159a1e16c9bb38c4052439f2b6b1a62addf739772a1d51057c89751f7518a3b1cd7b37c4ff7d621a54bca5b3936b137c3fd0001b4a09bfe02e11668f8f6204cb9ffa9817ec412c820b6b394ce2cc3a12546ebc5052ec1c74876f3de8fa22e19158198cd04c1336a792ea47e63c2bf4e85dbe7460501606c97409a906dae4e7ad84517a7955793365ca4f49b5f6829efe61f52069fa19cb30ce0a74d415898e7e134ed8c4106cc9d6be32577e9a024b9dfd8129cb5efb1ef282802fe0066aa41e587ac9ee20d6416010e1b0772a44b6d6d1eb4ddb32b80b288952b26323bbec16614c227e4599cf721484443f56571c7b048e58fe48b1786c44e4979e105196eb9b5803795b8aa3d3e3a8f85e10abeb0f7b43b9a62dbd0905af620309ef74f281f39b241c4b4c109ca7e0ec55b13eef8ee0544c820b833fcfd65b4a897458cb9aa376c6bdccf1032e099598452130bd38d28096322fd0001b6c9b1d3d60a49cdf61f9031694f9f790a4e0118ae39cce789df472c13bba207d101410af89ba4b2a88cff5eb09e53593bfdc69a4e2706633a45c77a6d8f4baff22c7e5e6b32278dbfe97100271b1fd1e64463fa6a757c4cf4b19954d1d363494664750fcde0f0b6e35f9e0f08f4fb1e438669f78b10e800943840b15541a31c3090437364159681974adba314bcac37e6cb3ec97c3d8e0cd52241a4c162787c199a256599b97e488b8a75a46d2c3d8af1951ddac43b0d338e739deed1ba26a8616f04d124244f365573b602697aab0b427a8f26f1a22de77c563cda13fc03a030817a837c497732c6108eac62102641176f62d5b8e73d0e27e17586d6e2c2c3fd0001ca7e381637425fa77d95f92b8b491ea1398f53d763a6ce32a2b2ff972ab74bd377723dde5302c487fcf7eac93b089457761ca341f143b7c5aeb51b33cf103f2f4ce4c282bc5bb8c7290c2a9dcc82fce3dcd9669f660872f602de46e869fccdd99c85c06d1d7bc9af4e93502e4ffc385b9641df8f21a25a14b8e4cf99cca023da529f8689d86418c37dbe3d4116e08069f3d08b9c952f4e2164dada9e62908d57cc68b264df8fcbfe449c21cae576adb8169a97294a6bbd369a58654cd182c8403080b3a5a916c107d7271311a291841f3e35e065d48f6023ec4118a3a88d417507ae86b6c74e6ced02c768e0f879844e22862c357a9fe22574e30c179e2243e62192455f24a5c214eba9746d2a8f6a47625b53e5e62bcecade179907fccb08e4b200218a8694fa7fa94c9c174c8e1525bb14dd946981e66b1c02178cde818615e3b6910021fca3cbe539842d405545a71cdacd7a795b6f6fd1c1095497ee8ee215d5cb8eca002039f76b8f7067cd9cf8bde783c8ea6edee7638113ec6729093749c2cc0c15d13c2091ecd1eb4b171326a6a26d764e72e09160ff2112e577149bc7c24e6c0907324421cdebc7f780b73aef4358c6f7e5c9a78ee95dce81c4c1bbbf570cde3c43ff5ca00021c6955241d2f09d2e90e33601fc139d0603919a1011ea7ee89f0e5d53727c45a3002037be468901ac92d7c3481ad63d364a93e2daa023bbcfaf4f6fa40cc54974de19209178cde568ebc660a7698cdd7f83d5932364eb8ac144e62cdc045a00ccda5201fd0001a3347a2cd4b1f5337653554be5fea273a5bf0292c0fd60908c6e699802079912372435166baf2f71b35ec0e00a714e9f7c10096d0f39a65f66f220057f369ce7c8221dddd00e90604f9de897ebf9f06afd41814b57350ca2d7ff7e08f60c94dc1aa087b7975936832a7050ae4a14b47b1986e70d61223cdc37dfeabc5e4212a869d953450202c9c12db23f8819faad59252173ac11c54020fd7324b4d39673befd2b585437affe9398e7967772d408c6db860ec512f94d02b0cbe9c9d414bb4be4b08c3a420a549efaedaa6f0aed7c74044785b611b2d65abfa33374748adb690a54c8719131e9dda9d46afc9dd09c8abc498d6de657920642d3d47a55f17fd3fd000189cc7f3247c6ede0becf1ec89f5d92142d25a713e037454409ccd146dc18c58319550275149342c690f00b1e060db34bf6ea34473c661ff9f32a40214a4715bbcea59a9ad6ff976e43b325135ca16377be53be39255f9e45ab4b3652d629a30500f9e37671d56952b215628ef42c1e49046476e9a760041003742751b158aa85c4e95f70e267d2631491c60cf09a174816890a2dcc2f8a38f296389c3ea66a6d577301297c242a6b9ca7465d8282f2e72e7673f9e1f1c8f470949c970854134de397c08c06d5d21fc487b2b9529c901c052e838a087f15b802d081e869cbb311efed66ba31c17c73cce1e0fc32d58d35e54bc5524776ee3f45d7b8f81fdad4d621b95f023b2202f5d7c814aa89c27b5e0bf5cd64b444c711c2e1c4b805af4fd58300208214f56ae87b268fd16d6df77efaa715fccd1fb1e6298c1c4f7a4f18d39c2f75fd00013fddc975fb72a66d5a7f3ba6df9c1ff55c7abb5a8f08317c6b346875e850223d87d72d71d9b17c11bdbe6eca34b521fe98dc8d31bef6fcfacec74e5c4a6c5f00ac769363df4720e17c3b687a42f29e8edbc20184dc9274d8546368e3fd2408bb68dd224b73a1487c10f4e29b0d8b9823f7c73db26ed16baa4c5d75b162cb5a97caff3cc8957072902538e0e698fad2e90b471777c5e8d90e5cd313933c2f3b30e49b6cf7ae7dcc09ab2e5e464601f093973d99c0815c3ea587d1803b4ca1d9bcc2ac20cb818f95d9239fbbe62c3356ba41f7dc9d2232f6221447fc4858bbf11ee382bfc639d9101943d958a59ff9b81007508c0879c4bf16885bcd6eb2383898fd00018ef6741f57def1a55a42b565ebe0451f0208762da626acaf0cbaac6d9bcec14e22c15660c2a0c5a23b27c445ad968a7e6b2daab11463040eb50799858b7a5063965f947234beb0d42fe1c2dc7bdce7fcda3de1bae384b62e798995eba4836dbee41a8a4de269c026018a22687ad77985d049bda1d39b79528b320ad3d22d893c547b4dc4acb57fac4e0603468beafdf54408ddb7d4c5db0cf14162d0d735b3fad10888f0048035481e5713b846761838504a36c1f956b072b46f11c7c6fc86c766c36fc7dfd5068855335b96162d8b299e3cd21060fed730310d7748c3d16f228b0eea1f37f5eee5453e3a19ebe1e60e4f2834d3338bf1887969de57ef02a1bbfd0001b50e09d8212707f0035a46513208bdba63a5d1fd7a7ea88e181d7a3de81b8afb4ea4b0428eff67c04b27372d73896fd9df443aa9b2a42cdb8665271bb1c54833454504dbc80645bc02366dc998d334b2723bbf5ee1e139265c107db84774b4884f26b57818d39d05b47d5bdab25778965a0627a96ec303c743ba57c0d32d0337a6d3dbe982284109eb0a7976221816cf3fa7f2e3cf739f30be83f253564af7f411358d9c5340fa5578120c48b617c088d9a3d0811a575bb1e96b746e25312ff6d87b9705c04a10123c7606b4c5d6658244121310ce3f1d062250b57ee51e35e0d7b787d38206fd9d26c70ef94f337cc7108ae8ffddf5fa314e6c4e5e1538fbb0fd000192fa78c12fea8a45902ac86e2878e8327b5f4c2ad9bec7d6167bc5590df49cbddda4d4d652f6f087f2ddc7a2813aa5b33048adc5f900eb4acd983bc86ea7b89f630b647a9ef42ab1bb6484ebc20989603144912ffd80158aff7e7d40a1a76489afb21f5f711ecc3ba613868a7aac4f28f2cd5440dfd064c4874d4e398a4718e10de8f7055e452271f01b27544af7035aed32259796962035d7bc37ef843cdccfacf969a1659e354ec27efda8756b09c0bf855f4fbf7598273154b3f517303b6169bdbe4ede890a90d3b34c917311027afeaca7dcd27158b04886dfd81803594292cdb3dbb77ad3a8df97df63095fd28c275b956cc61faddf770608b898d74ca9fd000138b1c34ee04d01368d9cd8ec4d70b97633951ada508c031470cb4cfb15a62426276c7442e7679f926e93325e287ab9ed87446c144be02e559dd076c9c5e1d6a6a7de45438076b1bd7b9ff5a821070877c1d9626925c1f47838e56bb6982b54fb9e131741bab5ea38aabbe4003538c454980dc3be9a436283edc32a3de0321f114ad32cf34e860ca36f18d476980910e564af57da498cd16d08af8b4d4696a9962adeb298d7af4c8c4ad9cb911d80a2115e833f0856833232f92f94cc9c3a6a14270f4c30ada671bacc9aa35bcd3c945dacbb01f4556ebac4e52adf8790dd49fa799584d227ab95afd2da9e67e5642a90e54fc8b693a07384577d04cc40861cc9fd00012c7977632367de82810aecc22a1a802a1aeb6ac54a4a79d8e61606a6eb72effe1abd74a0a18ef83709355e77cef5666f16d94c0d710f5ae3973bb26d07c0a436aebca4d156d42952cc8cedca94162225b5b4780cf47a6426757758957dcf2f638ead2312e67e140ee8c380949c0885c68b396517ba122d90a0ed184564bbe67bf1d0115c77857fbb29945ea00d8e809c295295494dc8cca091d900837b60b31a79bacfae59fe638ee8a2208942e27f605c452be3a4a43cf21e8e0f78e7cc20ffd2c546a0bbcc404b415e4eae2c7b9c2f9121bc741a6f8c5c28c9df3e0169cd86809f5e235ce17fb625f913f94b75b360f9097b625a5305040c55a4057dc4a68efd0001d594cafd37d880576656265ebb737602d6f3b9d0feb3147d8c1f6e1514ca64e3ee046bea5e6892d8db9c094198fb3f697ae6fc880075bbd461e9aab7425ac3a7fad84071b170739e7815d0c76d2ae7fefb718ec132b32a842c58cde33605488043aad9c5df6e58401994a38dc50779e348cd18582ca74aaadcce6df39b221d4235a88000eb2e3efed6744abd0b68836d56ff5d9ec973be549d52a195195725caf3b8cb683da9b96868d35727bf4b74dcbee838d9c39a33d15609fde1c2b345974a93c030952f52da7ac20b8c26b340fd9c08f56b97ccce21004af28ebe4946a91682f9738085cdd9a53e160346cfcf396cfb59465d114c600d9571634aa3c880fd00016c714ccc93e995491adb6718e9ef69fb2cc36044d9e6b73130606b9c845dfdc56e9518a120237f58fb6f2546bec6f47ea6a11f3d898baa47682fe3f537542fe9f6d3679398064a48a3ef8abc92e89eea84c6d8ca956b3c40e9b82b2a496bb1230f8789fc7b0befc061468049d416aca3fb41d4272304a728322684f9ca6125b91dea97bbbba8c83045b5ce8b3110d429d65998b3aed570ecfedd176e98a91eb3410cd501ba52d176bd2c8d05e94acc8f352b3cbe12adefb82d34e174765ef1197f8a93fd4e03c98967ddd0332733e5aa0ce87f63aff78a2b44d10ac63c1549d9a9c6808b743e6f785173924d736bc0890f2e68c1df72b0fb1632bd4679e87690fd0001e736a64f64d58af8d43f0980d29ba57dccea56ff13040270c926a2703401b83859a94d6cbf4cd62109053c9e6ddd1cc61ac649ebb1243037adec455891642de8f43b57afe96cb63736e3e7d735bbeec8d1dc2c698f37f0bcd85bd84cc6b7e25eab500c03f1c62ec730c24208e2df2830d32842277d9e5c9416a91217cbdad60d6a77a7127b09e7463354266a1130d1f04de9041f0f83d41246766027700fa9a02d10d2b0fcb0bd534d22ed38278ce177bbc1c429c09030f105a67db70011d3eb24754bbb31f8a6a98bde215f635409e4cb8c3d769efdd7f1561976bd29876c8a11a130cbb8e3fdf15fd9ad0329852dffd794f499345c3fee09eee21997a5c8a021b993d7cea74a8935b42df2d55606a8841b1a4a8fc0321701d5de9bce1dd1bee100fd00016e510cf2b787c67956990655ca7ba97aaea25163317e7ebfbcf2681b29b0b821aedb8f9b2e309d37f660ac7169dc8234a7e7e4d01e79164eb75f28d284c52ea3d7edff718aa96db6b0b6781366ab985d202823130ca53c2a8e5186fc18862348952e967b1a3e3636517c3e3c48d8fe5e4ef1d5e230ab584964c888c61393ee3d6e34c50446b86e68ebfd048ac86065f9a9c4bbfc2474027b612dcafbbb0416f12d3d856a96557529d1144a852ade77f50f600ecf3c0e00296576eb49a0b211baaa815cea78e1b7a56a1c698ff58488378722f58fdb1ab8bb0063654a8de8344041fddd04be1b4b2b1944ce9d1ebda667caf9ca00e5c2de892a9063a449edd8c1fd00017140014f19e2474e1cc4b40a5a8033f3ddfd960ef33c7e35432deabd85a5b2c18a27b85477c9e6fbb2d8a5e6c686078009b1f7868768fbb5f569ae3429fd64e49cb3a62f32fada09059982a03a20494bd2fd2caa75a01b3ecdbc32acbbd648905d56cd2aefc714af1c24bd6e8f06b1ddbb85e9882ddf8f0e67c654402bfe2e0d9ba404bb3da2d58305184949ce513b3784c3234b39d25e0c6df741683750cb46d7856e67a0d45f4839b5305f9965808d41cbcfb2ad3ad18cdba6744eab0148dd0c1d5e687b6e76bdb9408766b57297be6fdbcf9d7e3e6918c194ef32bb776b5ab85f3a6a164baf866d93ecc3acaefbac43a9fa267bc623cc136ad712b00d788efd00016d3db1d97d5d429249f5cd6dc35f61d0b1b44f1e433cd5e01d28afa6cd6718fc1432b77e8eb6418b0a4b6fcc6707cd3d5c6661cf57b3b73b78feae7c89e25eff2ec1d465be91a18b2f3bc4311919f410cfafe8ae8a06b4b947f9b6fd8ac17453c2f558dfd67f71829be66250d40c3aad6e3ff52d1c0c455e7a4f646445405cf1ad45ba65392bb622a770ab0f5a57e73d32d98ee49d73ac7dde7dad9b9c8e1da9d1e6aef0f9cd120bc1d2cbc5ad819190b758be385f2b0dd736184382af8aa419ac7d734881ab4728ea927b6655b7d3faa4a1e14caf6d38cc706a940bff9c9021e4b6c3ce516d0257deda35d2ac4e0423f01ac849b19b919b773a58f34a06c6b1fd0001464885fd950d292d5aaa6155164216521d2113d795d9aff389771e8f39ff3d96afb5e2359fc52aa6d6cbc3921a7a21e6f3fab62e748337e2cd212456e2b2f52dcb352a75902ac6fdcce93dcf027138be788aad5e09490bbce637751cfd5bda9ec540d7daa92eed7b27ff1bf66585fbe3b39db3de9dd386ecd7671f2395522c1c9006908afd04fe68d88194cbbc3216377cfc27c4fce55c8e558ebc4943cbb477a1172aa8b344c08d6fb853e64ff0f986b7f3e7cc3b2c3d8b2abbc43e08eff15787bfd6a9a8f98b207d8e2530c0c37a37ffcec2fecbd726b4b845ff48a44c1ec7031e4e663ec0042663ff81b9a7fe7d599695737511a685148fce0c3eb01513bb20ffe083f86d11f3c552efeba225a93eb8ea756f45c2e49a4dd08467b79a14000220fd4b0ae4d4c5a165ab75af08922366e89721ba7ee52751e0b3e64017787b9445fd0001d0a8ddbc130bbf7bcd20bdbc8728e812a45cdbe602dcea5826f753b94e1220cf698c1212464a17ed846b29db82b1cf5373241d4117b07a8b7d279d4e8511d22c47be22291e9ab56454becf533b771e5e602542d07828952d5ef900e2548739d57cbb6254c667bc50a0f97c11b7f3dc1624111f9d32cb0d85ac4106b41cd6d82db7aea1135334220163c190744b6daefa456f69c331facf9083af360db6f2a2c80c423357c8fd1bc28fcfd42db69d733efa9ecdff9df079bde1b73a63ee74e5af5c75b67a4824a72466e17b501f6057a68efc19627d115f19fbcb48c0e0889857f0d9191db5875ad6d336adfbf7f09989b2aecfe868c2efc2cc64af46d07c44b9fd000151620fcd4091a3d3e78e8a1f1b5ac9ae8bdd3760320ed9bea2e1b237110d7747e9894704336b958fc92eb200f06507cb56a12f202a8b098bcec5b7b6941dccc18d2bd968538185dbfdbb6ea61eaee22aa8ad24d73df0adfcfafaec181fda3626479710bc19835a5aa7a3b9afd8166b89f5aee8d52589059eda61f19f6319335dfac7765a9a9e22cce0fb3236eeba6ce250ea0b7cfc4a021ca3c88859f556dc1137349a7ad5a628bc47267ad91ff86174a2fe74e3ab298ae8917d6a57a916f00b16bc0f3584b12b0d63141a20b1ed54c6551c6dfa5647783dd9acc68ed75044faf6745161c1ee4abcd9969ff9e01f14791de7d0c8e44e77a5b249e2da833ad3bbfd000119884821e74482b639ec1f8484eb6199a01d6e0a3606e25527d7a9fdbdabaad9f378a9aab04a153b1003d520d03f25a9e41c82504ad6de9fa6cda30a3ee1128c35f49d469a79b3c190ab0fab92d9977478c7ddabb6a66f291b58756e040892f44ebf6c8d0ba3cdf5d9335c8b05b0d34a8f4e832c54979274f5d4554af2d05aec3d51a3cbe03282c9c104f664fc39863c3a23396e762a5a8b5ba18b3c84f0f49f8b7cb6f627905a4fec65e5ab41e868561dba5cc8bcaa8c201d613eb678342aaba5e5d44f7ad7a58810129aaea2e6bb9850ef022e54a50b18e5fbbb76b93f050c31d279e66cd51a29c42591b18db05e88283e52070e6eeffd8fa447bce2f22eb921b2e3f53f38bb201511b9e1d5bbf22bf9e23be137543e34d81a0b194f373dbaa600fd00010ef2d9c59966c760260612842d16526b4352a5f05de655fd0bf50546be1d893d90f4b8264488467880be2c4d7f3c577e6b68335f1e0764fdb16d6fc44fc5bc2c1660798e6b31bdcafbd33a9edb44e48dc37306abe9ea761cd2077e977a6d5b92aff3cdc33f644f47d90fa9a4b172faf29e264c9d55d27cc4e7b7e5e891adb566d172207406ac400348c386e74716a518cfde36169e4cbd8d3093c8d85b99e54474fb7c7ebec5a3beb18b664b953f4d9037041c45738e87c53d55eb92fe862a78627a8f4f3f8f3ef01102b8df05c9c1d81da85e36bff90943e0dd93efc00edbec664fe16f03c8e79d3e105803c50a606f9812d3717921477e224efc9c38604e932116d048938c5e50af925d722ada7f78de5fb1020ef09d45e001364d0ac87ea4b800fd00015a2d2be1965546e9ffe759719faf9960648c9183812a7db83a24b0ea52bc26e3774ac36fcef82756ad386332d49551a147e87ca68fedb289b965a4088b3de6506378af1f858c1c995313e189684bbd3e484499dd7a26097ee6e89ff1d0b6d4c6c03917c53a95d9fd1b6f9a8e59e1687506a5499adea9c1bbf2f63b14207daebf64c8bd5742bec97f6364560cb3d687f8a1ce12a197e87df8b23eff607c97849672fcd5803b43bc8b6dc2090d7b99299248007a3207621c0b40e6fcecc3f68b2888f5abc842a44e6f019391449a7356f9e2637c77612b342fe61e76b307ffb780f529c6c84a6e6ad8d7553021070499e85542cd288a543bb0d318b7abca62c48721047951dd48307113449db6a3f997999f421c62b4524e5baebffef1ed21a6a1a800fd0001c4594030b3c64f25c491e6a5ab25b959438c11265e0fb2e5b2c58e3d59c86d642aff89f4e5137f08ee4871df4098a5748795d596baeadd2db0b006e02822ae2c5f6a93c5281b82a7d60f8c390a264d293f1769d55a5b1f78f0f65f7694477ca79ec16677993d5b6606298171df85a7a8c9bc74a2438618d2aeb384de706b797758dd418b2b5d59dff089f7d4f7f21a8390a3a707c08ea4a238682670a0d80297ba68febea9c4c5afccc241d9f95aa77366880ccf891174b33d95f462839d2820451611e8453d981a703595e8fc73df0a4963c1baab591b7f3dbbbf62b50dc7bbfeb05ec2ecdb1a63d06ec20c4311451b4129a08b94049263e180411ce7b8b7b120a96a6184af4c9ac706b2fb01d6389d94cc4e3930925a5b2bdac9e96dbfa42a78fd0001cb6e8b1c0da834c4fc0d797fc3521eda3ddacade8a4ecfeb518a2e2e8a234d2f6901a7eb4d117404328c70a5c24f36236e88eb1dd19a7e9cf4ec7582f472da053e283dc193d70bc71867d2a348521a860fccebe0b45958f1b5919cb833d79802640b7665b7ec45135b24108eac8a882121b6e6734dcd327b506a434cea9298e6067c36457858e3c18d88a320ce33cc3861f5329bc1f9a8f4af57caf2c134051b12dbb58e309d7bfe9028c3b9b4179759095fa531cf20bdf18134c517ceddfb38b4d94849c3d7eae9b46c353bc43d2f8345e345f818e328875c1bdcd754b706258779c7c30592d0a4c4b5cb557eaad484d1cef2bd4d98d12e70b8fda33f3a5e8320486c47e0fc0d0e679b413041e800ed28ca862ceec23c1a937954131b960fdb3320a3e4a1a7ded3a425d38b144b090d9fb0aeb9ba631622041a702626583d41142520f6fded7e7b2408e70ac4ab0b4d23bc2571cfb9048b0bc738dfd0d6507549a451fd0001656bbe84db36e12bf3c78c07ba3f561d2c2687aaeeef2e2da17cb00f060e025a993c12551c12bd4d75c4c116d18951515f42c5628b0858d85b8afe5deb0aa14a2e33d147eb62b9a52bd4769b6a97382d6c39e5f70e727ee0c9a4684fcf9a03e7232394b4ecc6a2d32e27fe2b436b1081bcb4f3c8bc844e9cb1cf9b828bfc155a2467d506be2f89c9a36bfe2d19125fb21666ed4625cf881ffba75e67d209fde2742d5a46634019cf1f96c1e649a9dd58edbc374b9d6220bccf20055104e8ae8917537fd07f69e12e0b8200af3d924997ee33a7d1e9eb3ebba741f0edd1b59f05e1f279d0c4f7106276968fac8055088bd5f57840438a2776bb21693d9587d188fd00011ae402863053cdeb79711a4c4e7472a1559d86f89bc4daaa6865eced1126197f3c43d78ba16fb2d6508632ab4712b13c3f45d674064a51867bcba96a71b7683fa69ad337fd0c50e7927b913f025fcc47adddf1376a053280cd0fc45bbf29767b4555bbc4054a824c11dc93c3648b3e1c2ca42a5dc5f536eca084370ef65c5a6229bcac295c3fa578fc22eab793205cdc8d37fe3cbf7dc6e1a7c53ff3c7e4d226f0a0d4667bad282c625695d4a1ad88931ca894d4931b19b09c56d2f72e72f62600c97348ed8814f0841baea716d1e0d90f26f8c3932a1e66dd1e8c8e039d3e891ab7be0a887c16ea9fbf3dd7f2c7200587ef56cd0e75e8e828aedcef6198e6d6fd0001a265b258bad27b42d12a12e43a25bf566f2b77f6f0924e8e0c32f294768fd6d9f92e5a15dcc94a2067c71a1f9740700ee0e7f626d35fad2c441b176a077fe681515cb0c8613b0b43c708895c9ca5a41745ca87cc5e8d02e272a484be75cd9afc7478b98bd4c030c3dd4885c5214efbe70cf9a1f8a2616e2eadc150f979e1ca8c389b6fa288889464886bbdaee7539c6bba71ad0927e455d45db2c7371a5b15ccd8c7e7e91592e9bd057d85a18a9a9d1165a84329a6c7beab031f2819cf36a26aeaa4cbef4e7871c472b9b363a1a5d597005cb98e6828a7b6b7ae81cbd036f8d8afbc6289efacedbe51c10f27462c81525b18d119ab4527d9c6db52cde19cfdddfd000143a024d1136d92c3d0de09ce4a3837fff20ce4c4307fca87b1a09acdba6a1df122cc69dca4ec3ca89118ade730ec8959d0dd84db0eff5ab2ff71793af68a2d6bf3a301edce9cf1088046b3177c18d90f6318e2bea3a071469873d1a320d5036a6ea1375ce17113721f01852e1c436745536bba80365d2ee1060a73098f99983d18511059ecac21b84131d845bddfc589a1a4c195ee1d89ba9845c09d681a87c3fe2322cdf571b4a31756d2de38276b97ecef325f4ada73b747d78899c3f84aeec26fc9732ef5843f8b2d7af0fee0f04e01f85731eb9fde3f0a69c4c0aad09f51db5cd03db3627cce5d2a1dfe9910817efc2e53ebf9f79afaf09521a3f14980cd20d6c0a0d48152ef8efbf7a58b809f5c28186addc9690ba0857cbf2c96e395e92afd0001781c2002615bfb1b7d35b2e208a1df0bd8f95f2d639422c213683828227885660bb231058546848fd01763a1ae99e0a7a1040a0f398d8171b45ffc4a58a4439a5addbad802fe79c71a4a27fea8ed229c51d6cb26c3e21127aeccd2f0e31ff1c7d38987c95b917d4c86439a7a0d54b3985ccc3072727c654bea4d473676a45f13ac693de273581cd6f864fba7ff00f3e61cabfb689689c49849419ca1cf489cf9db2138f65c1445b74a0e0cc83e8368e6e79149e699b6e64c77c70ac5156bfa98794cd0c561732fc7e3623673e1f0ebc09de026d9745c4986d498762be6799cf99143fca5d69d04283b504c8d8e325c8811853d467b72e204417c7e35064ce7bcfd0001e42f7a528e3e1de18bdde6e2d9888886aea8ab8eb149299c32c68f6c93a19889efc05e641037687a091933cc83bfbdfac77d48a2828bac6b0625260d4b9da29b0d215d403c6d3aaaf33addeeac4d46875cbc4e3edc1a865d6fe0b6a588631188897cfd131672a8c5d098c0ff8ec5b17bcaecac1d78f83b5296408c994c1e81f93021da25ca403ab6694fb3e82ed260e88067e3edcd8cd0e70ce4c79b49962096eeb1eeb2b17be69033a338ebfadaaa1293f7fe17fb7ddce051d9eb82c84d639bab4a415353ae82dfed2996a26d184189ff1f25d3196c67f34ae50a39bdc2f711ed6468b364d1f0ea8eb29f486dc6c2f6dc59a24acf4772bd542557e9ef4b5b93fd00015ea90490404b84482136f214f4497c09a0ca87dcebecc03ac3152ae1c3e87b945f721137c7a309e1036d0e5d94c6cce38c36b1645a62c7160ca45abcfb5c165eb696154ae38684002a150ab45f1b8f1bb69b28757e2503967a1432090b6c90ce7e3671860e40e95200f8a1ac1ad927b49bdc0a66472eac7123c383ba28578b149f121ad8b1ead1e1908858a640ebebd2e1b8a4f787e5f41d573168493115448edb0de580a8c281b783afe2b62ac6ea243d021187367df9fd28f97e2ca7c8856d89c64a4c3c5e2147aea8120b4bc8b2d0b8ec5b7edeed35d24a800760e82ab19cb7363c7b2fcde6200b8e63e2b698489e9dc0bc4c8f1ba9ff68b59a7277038eb920fe94cb66b60142227c026662ddbc3dc29b373c5c805c365b245c2f69152f1e2b2007e3634d06ba18b973add33bf3e23a7175729a9442fc4213adcb0e5a52e2cc272096af7547b4220ef6335cf5fe47c75896ef2d27e58acb6d7b444b3645ad1a9370fd0001f3642f6dab0eedbdf04e554106719fde491a9bfe00e8228f250e0035f5a95782bef5680a15e6148467d4c7db9e22d9a4bc766ba884940645845b25ace95d405744929adc2c63e41e4c807e33a0d514919c09e855c9d77690be00720d83dcf2b2276e157d39b7acb3ae262e65a8a09ff49478fcd67765dd03b545d7e83bf194ee6b0c5f83c41f7c470e0a1c3f1014a7afc2b7149c01b3f1181eeee4ce8a9be47f0f7f897a05683629d6164fb882e1b67765e7560e7f5c6be76ad9902a755c6af0c455156b93f14e618f533feb9d351bb956da352b7a9d63cc5082d6f9768d80f1bb703c84be2bd75b848f06925f47e226c424c0ae8ad4293b0e9c330cdb16bbddfd000158cc2778c31d0436228349fd19e0428de5916401bbed1f3668014cc51cc6b42381c32e5a7d5ec4d194037b0544284c52e151b652e2733b17065b2a98fc1f5ff30a8acf5dc7522b64eb94a8a71526e2aff0acba058b541fa412bb5344eaae4945ace66b4f9251e842e4b52feae5ad89ae1ce3617debbb551c09c87c6e6e69b7942f1178db84dd758fcb3f63d658e3f48c1a47b725cd2e003a59b8e318950a45fd908c6c4d53d706dfcc2041c046324e3925c9ca032f62cc825f0c11c9fff3fed99f616837244c7d71b3a8d79a8d5bba3284280b59ca1d0ed044801b7d4d6148f014c8c04e8859d9c3d074a7ad77b86ea0ae39fde03fb33eda53fe5c1728cf2daafd0001991691f21883d17fbf12b24e86f0f639967240dcf120c01713ad612ce32a7b8a9c911248414d8a2b836e82c827270f60f154d5de0efb7aec5b3db3f8b9cedbcda84aedea5fd0988ed0069871fda9db5ec2a1ba0f04592048f0040599023ac01e758886daa3fdd9190bb1c8ea29898cdc711e8e8e6c4497ae95ee2e5a6730bd8388d8fa812b21c9b97af84a7cc9f790139b0005dc86efe16436e63982fe8d8ca6bc5bea86b2297a0726dab7704fdcc8a3f6c273eb0f8aa008ad1e6c4a985d7cefe05d3b24cfd195d2fae5392a48be5bd50386244fa9002f95ce18efe97be356a3c5b3990172f812987d0fa10d7fdf9afcd20e165697e3e8f1fa564d1f03010f8f20d53cbea6789dd88800af410af54c3c346483fa085c6e02c088092372ce828e2afd0001f903226d53becf91fe3ee0e556a7ddd652e179dc3c2de5d7af38f380c9916791a37e149ec620429b47e5e0c016b898ee1dc45db857c93a718daeab3167c3c336b22692da51bbf7ef1bc42cba25af0b1fa89df2adcf41535803ad6e80ff1e1f57c80514f1d091040aec55e87c4810ea31ba22e4f93a101d71e324cfb2d84e381f1a59a601ea97907013e119a24a468b27558b68de170690e71bc3c2d7361ca7f77d116b642516d9cb128a70d6bfba83b2c22420059e8c22c9f794f2e144a3a065c942d3276253013efaa88a80e3d7f3c32dc249347a6df656df62d4f9482cc8470bd1bebda925d47c5cd9a54ce81d7bda23c2d0434a98a1f73911c6172facb08a21cb1b1935a2667416f5cb810c787fac55fe8e14146721452870f27b1d5a14fcce0021600ca51450ca3b29e9ff6b388f4df5286db585d027f68fbbd1d4a95fd756318700fd0001b941fa0b95cbce10d49d29c80ac6c45bb624fbcef94b9bcc75d3593a5e11ed85488b6332f9d059e0123d5df6486dd2f57a9239d137d46f3b9c0120d391d1f06cd48c13a0b847020d0832b15162461811662eadcaa2757e2b6b2240d478e7411c7e807e09ef824ecfb3681b49aa72a3319dd310d8efd930ffa7751a222e73f198032f2dfa00e978c9542dc476ca44b161fe470a5f63759de5086a18fc92aa375c608841c6ea41ebc6fb86dea22a8987f7d9abac948d5e67a173eee3b9b90c323c4f2624f53fcaaadd79427a36f560ce6cbd99872d8119acd2173935b0331217e33ceb4a0ef314e45c1a90ad06a46948a38a00feac8f58d8780e6d15ff6e013dbe214cc25d39b2def7e68e2d5c7afd69c5d629265f750b683dd660040dd3220a2ad600209901a1f845f602ecd4f341e7b68c6787561b4f18d7819c8b319dba6836a42517216578b022ea0911e03a47f0814046cea045fa63bb506730b0b491614417df91f900fd0001ab0bc038363d54f8e9e8ebed6a498b0f989c8ee56c81720bd66f71d0d97477d0db56d4481cc0e57c2316f4bd3a6843f86e5b28152436ba23f377dac267c7bb6501666efefb705be623d0491dd42a1a5397f45b6be6ceb1e0499842914f56296a34f304320f5b623ae7e16379d89394b7057d1b92de4c913265ba231d81dbf9e4b586704803baf1cf0fd474a721bd65206df02888dd77df03c831f8433f3b2c7cf7e1211c7c85a975d129f33734fcf77c09aaf68b681da7c506e8c89ac5394589d185117b722b757e307ccf31e9ddc1b9131633bd684ad458fdef09d346566eb4df920801ec4ac019081feda518cafe1ff9f9197b1473ddb18d3349652db870e0fd00014168b6756041ac27a58e4160cdc78c2cca30e144cfac7c58d40b5521c76ae171399e4e22bd3eb5a59d0a44666cbd8d38c4983f8e2bac6fa5ee84c86adbf9679bf8631dec545667463df5fbfdd5ff2d0f6f9021a4a03510e291253bd133520d5366e3c272bf8ec41a14b15aea97c420ff263bb52dacb3c3962359312e5a4690483434ba5f592057dc449727f03768f3756c24c85814e1204d2d90cad6e656d39e7dd7b9c4bfcd103dac62415b0d64901b01278602553c149f6d64342f16757b5ca1033494395404fe1ac7ae33d796be12b0d4f986de23186ecdbdc8477a742fdc973b34b312f5984b74faa42f5f62dd4938f76f5d7ec141249902825d81ab2d88fd000193a2f7ea915b7e506459b8484c43d305deca93a84b3c313a6cea7286d485395158806c3d7f3155f3ddadbfc913cbd7673193a951b356ae208319ecf406172cfbbad940f9fb6accde0c73ea5a19037d5d8644b4de22db968851a303717f473c491465712da34aba4e5dbc31361163ae1492bb3435779569598639943640b6e14233d8888aefa72580e22091b6848592d5b2273cd2df009d614da03d0c0803b4320800094dcbaaa337fd210820e07a8deabbd595d2b59b3bee5c7a27e585a1cef44e532de83f08f3df43d68f5934ca69776b8ee8ddc42969970d64145c093d4e989cf32ba71f750cd03b5c818c33462d05c6477ec1f0ec81c48e4b119d05d31cd5fd00010b1cbcbad09df7edcebe79a913033895c66686f078402893059e4a986fb76d28e89324f5b04ead2c2aa0a05786faa1e8f66fbaf66b7ca94bf52123e6afe4dab54bc33e5860e8766031dd256edf40b294f55062e613cafac04f77e284f5097e88cd7aaf46fd28f4fc24ef1ec1aa0bebbb3da5b504e4dca8dfc13ed99e4371dea6f3bc0f78cdf6ff47548e5a426c92095da045e5ccf45f446116e2c9567ed01d62c1fd3a78c62aa359118f77705174db6a4faacaa0b6d49b4da38e42b20e9cd2ed7979a0f6da28964e0b3fb755046f0a5477dda7465e00d13c8751c36255b7f922dbfe108a14e359257ad607dd7e1c9ccf330779135714b01746a25ddd11e565c821713c8702e50956f6f6ed9c283cfdd08938c8ccf08ada04309e47fe9ddf5cf5dc00fd0001f5e2772a310c56c1343104f8f618a6408dbdd5e421e31856f271fb33329d5055c4c73e06870feb8f7b298cb9b403dd6c4e87dd2137ac84df583c58ffd627b0c52996bea39da5959ccce7968455d3b4ac512c3d552cf63f2da74d6de8e8f037eca47a715d0d9398502b287e5f1034e536a84839bac21ae9958da81fe54c67165fc650f19679c9a628b74fb40c65257dc5b50f690263f9c1eaf41c3365c0e23f91aa7ade32a352c8b88b40b2d7acabbd085185f7737be6f5a7c342e2c908e8a4f997ec949e4bfd6ca7b3da894fd4581c81141cbfc01afc3eb86bc91c304c18c1dccde49d8b61e41d922fcb02162e444dd4931a076bb072ee3a6d5cebbe8683e992fd0001197c654987867c3fa8c71ce3718a70756063be0179e55bf302daafbbdfdd5669ec38e140e3204b260b1d54373f84657101147e672120d735c179fe2dc5fdf7dbb8aadee63b867e31d9d1242e45bd23ed510dc7a6d44ea7806159895a2293c0898bfa6d0018d9864f584a59003a8d4e3a38eae3ec7c9e35d1e5985acd544547ccc1e31024f05bfd5dc07ccbf535b2e0a0a703548e355eb9d1acd8a59684ba0fb674075011c7be7cc899c30ea6c1df0dc34c9366f47743aa12991192cc836db7fd5a3b0306d6905a82e13b729038c70fd3bea01def02b93cd2e71439a9fdd5fb93da6a72436ce9f5b431b7e18c76d8173964d5a02e24195e761d888a6a2532d785fd000177fcd4e94194a3c34efea9f8f04faccbdeae9f0eb8662b0195db08e2b41a08fed52ab7060465ecebdb2eeed7a768c4f65ed7b5193151a56c752cdf97cd4c6be8daf95c16c6703ec95b6cb541506c715983be6384031685d48190e611ddbded6fa377affb759a1f289523e56761857afb3c33b68f743c59a6bf2098a2f5bf3c1c8a3d41224dd12ef247874ff5ab6257cc3e29a65a1c16f3ecc26108142d1899a340be3db55556258a348f51ef61e8e73fa9db743f08df3d31919f2d9484603f0281422c2cfbd66959dc138c2176d6e242a5a04d5b1cbd8c39ed051486869f8b0f3ecdaf42498be6016dc9d49ae52220612a32f50259fb9a8be0cab88deafd56b12024a02c37de6161be5eee8cf63c3b0d16d0bdca07ad59fa0532678b864c13bc20c87f061046d5218e768801c69818dadd24e62aa4625d5a2af09b08f9eba8efb5e29fc2977184990fcd81a0a3f896691051da425d7b99292d8ad7a3bbae5ed9503c5b761907097dd6457a5444b9c85ccc829d5901d46cd917ead52a3316f96588a7b508501be9f69c73d97e0db9d615f20e50712f377663f6c47f42d005db72b225048abeb8d6e3a60afbe21cd13d4539b7d39806952d745a7c49a552175135d840a3f9b9e9ef6ba3c46e9f4bd42f9bbde0d5d5abda8cf06538d3492aa856d8708285c0aa99567adaf6e0d4b28feec0a55e49879ac8ee966e4520e5eeae3e6b4473cb710b4213973850ea6ff0331b04ccffab2c2c3f55ff28b8bdf644e4c19982916d01e28d745302b079066c61fc0e8e1c90931f122893f7f5eb86e9111f98cf36e719129668dcc4718c52d0c4c6a1a941b939e5e744d61aa9fb1aed6eca5fe062cd109b15abfc5851ff77c026e9ba7023b298d40acb1ae9884671ff1776aedabd859f2a481eb18b963afae2e2ec41e3f3671e9dfaeab234d6aaf97249f4702e706437d8e36f517b3b227bae68b79064a3fbe999bc2d75912d970826908dc17b815ec0f030e20b24f17c527557e36695abd05f67c60475a0c74aff42b5e7fd13efc3b1c3bf5ae6e3251731ffaad110c4210c9d6d78d69d6f68cc31ca99fe783a12fca506af01e28234f01e1b30dcdccdfbe696bd5703ddbf0554c8c4863edb0f252e2a9daa54d4900d8fd6aef61b8a8f1e165eea79a3f20663b08722762f2c548e0cd10cf0d2d8e8b9239b638d02c49749d55b721a6c81d2554b51b49191518150664b7e4dbde3ae02b36ab3ec9250290c8cee6b7371e0d3c7ad64d267cf463c4c82cc5a325aa72461345c4f45c8753de23bc148527b0e982511bed9e4bcd4cc37e1e3b5089373fcbfbef9a111c96cc79e9f37d619c0d68598541f2a37ea0fd614ca310c915c0d412d2eeee8f7d71e4a250bcc60d68ab3f296a5f75d75d627e5913aedf3646f733701218878049c420ce0089ded11b087f16111e397d0f2e354f1df0a858aaf07eeb8e80002210305cf1786fa950ae9b553390d6d62e2b285ebaeb978822439e0922403f9cc7dbc473045022100b807fa7bc196a7b2d7a3000e5e1870e2ff488bfd6e2850aeaefb3c606f28379e022009c3cec446550e5cb04483404a677c4b8406d85c62cb4d714e5ca3a50aa02f260028e8073a460a05174876e8001a1976a914dbe6d470fa9fe4d037043533eff4f80aeef0c8d288ac222244524271336345713233515955416d48736f634336664452764a453753723548455a" ) func init() { diff --git a/bchain/coins/pivx/pivxrpc.go b/bchain/coins/pivx/pivxrpc.go index 440f09e68d..12a0650db9 100644 --- a/bchain/coins/pivx/pivxrpc.go +++ b/bchain/coins/pivx/pivxrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // PivXRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/polis/polisparser.go b/bchain/coins/polis/polisparser.go index cc036cd13a..e004d99051 100644 --- a/bchain/coins/polis/polisparser.go +++ b/bchain/coins/polis/polisparser.go @@ -3,7 +3,7 @@ package polis import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/btc" ) // magic numbers diff --git a/bchain/coins/polis/polisparser_test.go b/bchain/coins/polis/polisparser_test.go index 915d70035d..5f8e0b93c7 100644 --- a/bchain/coins/polis/polisparser_test.go +++ b/bchain/coins/polis/polisparser_test.go @@ -14,8 +14,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) type testBlock struct { diff --git a/bchain/coins/polis/polisrpc.go b/bchain/coins/polis/polisrpc.go index 009d6d9825..963cef4bf6 100644 --- a/bchain/coins/polis/polisrpc.go +++ b/bchain/coins/polis/polisrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // PolisRPC is an interface to JSON-RPC bitcoind service. 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 eb0c32046a..da3f0aee88 100644 --- a/bchain/coins/qtum/qtumparser.go +++ b/bchain/coins/qtum/qtumparser.go @@ -7,9 +7,9 @@ import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) // magic numbers @@ -40,14 +40,16 @@ func init() { // QtumParser handle type QtumParser struct { - *btc.BitcoinLikeParser + *btc.BitcoinParser } // NewQtumParser returns new DashParser instance func NewQtumParser(params *chaincfg.Params, c *btc.Configuration) *QtumParser { - return &QtumParser{ - BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c), + p := &QtumParser{ + BitcoinParser: btc.NewBitcoinParser(params, c), } + p.VSizeSupport = false + return p } // GetChainParams contains network parameters for the main Qtum network, @@ -122,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/qtum/qtumparser_test.go b/bchain/coins/qtum/qtumparser_test.go index 1dc0893806..e1465b6c58 100644 --- a/bchain/coins/qtum/qtumparser_test.go +++ b/bchain/coins/qtum/qtumparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { diff --git a/bchain/coins/qtum/qtumrpc.go b/bchain/coins/qtum/qtumrpc.go index b73f938e2c..ae7163f4f0 100644 --- a/bchain/coins/qtum/qtumrpc.go +++ b/bchain/coins/qtum/qtumrpc.go @@ -5,8 +5,8 @@ import ( "math/big" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // QtumRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/ravencoin/ravencoinparser.go b/bchain/coins/ravencoin/ravencoinparser.go index 67d754f883..72efd7038f 100644 --- a/bchain/coins/ravencoin/ravencoinparser.go +++ b/bchain/coins/ravencoin/ravencoinparser.go @@ -3,8 +3,8 @@ package ravencoin import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // magic numbers diff --git a/bchain/coins/ravencoin/ravencoinparser_test.go b/bchain/coins/ravencoin/ravencoinparser_test.go index ed4bff5ea9..dafa126b99 100644 --- a/bchain/coins/ravencoin/ravencoinparser_test.go +++ b/bchain/coins/ravencoin/ravencoinparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -74,10 +74,10 @@ func Test_GetAddrDescFromAddress_Mainnet(t *testing.T) { var ( testTx1 bchain.Tx - testTxPacked1 = "0a20d4d3a093586eae0c3668fd288d9e24955928a894c20b551b38dd18c99b123a7c12e1010200000001c171348ffc8976074fa064e48598a816fce3798afc635fb67d99580e50b8e614000000006a473044022009e07574fa543ad259bd3334eb365c655c96d310c578b64c24d7f77fa7dc591c0220427d8ae6eacd1ca2d1994e9ec49cb322aacdde98e4bdb065e0fce81162fb3aa9012102d46827546548b9b47ae1e9e84fc4e53513e0987eeb1dd41220ba39f67d3bf46affffffff02f8137114000000001976a914587a2afa560ccaeaeb67cb72a0db7e2573a179e488ace0c48110000000001976a914d85e6ab66ab0b2c4cfd40ca3b0a779529da5799288ac0000000018c7e1b3e5052000288491283298010a00122014e6b8500e58997db65f63fc8a79e3fc16a89885e464a04f077689fc8f3471c11800226a473044022009e07574fa543ad259bd3334eb365c655c96d310c578b64c24d7f77fa7dc591c0220427d8ae6eacd1ca2d1994e9ec49cb322aacdde98e4bdb065e0fce81162fb3aa9012102d46827546548b9b47ae1e9e84fc4e53513e0987eeb1dd41220ba39f67d3bf46a28ffffffff0f3a470a04147113f810001a1976a914587a2afa560ccaeaeb67cb72a0db7e2573a179e488ac222252484d31746d64766b6b3776446f69477877554a414d4e4e6d447179775a3574456e3a470a041081c4e010011a1976a914d85e6ab66ab0b2c4cfd40ca3b0a779529da5799288ac2222525631463939623955424272434d38614e4b7567737173444d3869716f4371374d744002" + testTxPacked1 = "0a20d4d3a093586eae0c3668fd288d9e24955928a894c20b551b38dd18c99b123a7c12e1010200000001c171348ffc8976074fa064e48598a816fce3798afc635fb67d99580e50b8e614000000006a473044022009e07574fa543ad259bd3334eb365c655c96d310c578b64c24d7f77fa7dc591c0220427d8ae6eacd1ca2d1994e9ec49cb322aacdde98e4bdb065e0fce81162fb3aa9012102d46827546548b9b47ae1e9e84fc4e53513e0987eeb1dd41220ba39f67d3bf46affffffff02f8137114000000001976a914587a2afa560ccaeaeb67cb72a0db7e2573a179e488ace0c48110000000001976a914d85e6ab66ab0b2c4cfd40ca3b0a779529da5799288ac0000000018c7e1b3e50528849128329401122014e6b8500e58997db65f63fc8a79e3fc16a89885e464a04f077689fc8f3471c1226a473044022009e07574fa543ad259bd3334eb365c655c96d310c578b64c24d7f77fa7dc591c0220427d8ae6eacd1ca2d1994e9ec49cb322aacdde98e4bdb065e0fce81162fb3aa9012102d46827546548b9b47ae1e9e84fc4e53513e0987eeb1dd41220ba39f67d3bf46a28ffffffff0f3a450a04147113f81a1976a914587a2afa560ccaeaeb67cb72a0db7e2573a179e488ac222252484d31746d64766b6b3776446f69477877554a414d4e4e6d447179775a3574456e3a470a041081c4e010011a1976a914d85e6ab66ab0b2c4cfd40ca3b0a779529da5799288ac2222525631463939623955424272434d38614e4b7567737173444d3869716f4371374d744002" testTx2 bchain.Tx - testTxPacked2 = "0a208e480d5c1bf7f11d1cbe396ab7dc14e01ea4e1aff45de7c055924f61304ad43412f40202000000029e2e14113b2f55726eebaa440edec707fcec3a31ce28fa125afea1e755fb6850010000006a47304402204034c3862f221551cffb2aa809f621f989a75cdb549c789a5ceb3a82c0bcc21c022001b4638f5d73fdd406a4dd9bf99be3dfca4a572b8f40f09b8fd495a7756c0db70121027a32ef45aef2f720ccf585f6fb0b8a7653db89cacc3320e5b385146851aba705fefffffff3b240ae32c542786876fcf23b4b2ab4c34ef077912898ee529756ed4ba35910000000006a47304402204d442645597b13abb85e96e5acd34eff50a4418822fe6a37ed378cdd24574dff02205ae667c56eab63cc45a51063f15b72136fd76e97c46af29bd28e8c4d405aa211012102cde27d7b29331ea3fef909a8d91f6f7753e99a3dd129914be50df26eed73fab3feffffff028447bf38000000001976a9146d7badec5426b880df25a3afc50e476c2423b34b88acb26b556a740000001976a914b3020d0ab85710151fa509d5d9a4e783903d681888ac83080a0018c7e1b3e50520839128288491283298010a0012205068fb55e7a1fe5a12fa28ce313aecfc07c7de0e44aaeb6e72552f3b11142e9e1801226a47304402204034c3862f221551cffb2aa809f621f989a75cdb549c789a5ceb3a82c0bcc21c022001b4638f5d73fdd406a4dd9bf99be3dfca4a572b8f40f09b8fd495a7756c0db70121027a32ef45aef2f720ccf585f6fb0b8a7653db89cacc3320e5b385146851aba70528feffffff0f3298010a0012201059a34bed569752ee98289177f04ec3b42a4b3bf2fc76687842c532ae40b2f31800226a47304402204d442645597b13abb85e96e5acd34eff50a4418822fe6a37ed378cdd24574dff02205ae667c56eab63cc45a51063f15b72136fd76e97c46af29bd28e8c4d405aa211012102cde27d7b29331ea3fef909a8d91f6f7753e99a3dd129914be50df26eed73fab328feffffff0f3a470a0438bf478410001a1976a9146d7badec5426b880df25a3afc50e476c2423b34b88ac2222524b4735747057776a6874716464546741335168556837516d4b637576426e6842583a480a05746a556bb210011a1976a914b3020d0ab85710151fa509d5d9a4e783903d681888ac222252526268564d624c6675657a485077554d756a546d4446417a76363459396d4a71644002" + testTxPacked2 = "0a208e480d5c1bf7f11d1cbe396ab7dc14e01ea4e1aff45de7c055924f61304ad43412f40202000000029e2e14113b2f55726eebaa440edec707fcec3a31ce28fa125afea1e755fb6850010000006a47304402204034c3862f221551cffb2aa809f621f989a75cdb549c789a5ceb3a82c0bcc21c022001b4638f5d73fdd406a4dd9bf99be3dfca4a572b8f40f09b8fd495a7756c0db70121027a32ef45aef2f720ccf585f6fb0b8a7653db89cacc3320e5b385146851aba705fefffffff3b240ae32c542786876fcf23b4b2ab4c34ef077912898ee529756ed4ba35910000000006a47304402204d442645597b13abb85e96e5acd34eff50a4418822fe6a37ed378cdd24574dff02205ae667c56eab63cc45a51063f15b72136fd76e97c46af29bd28e8c4d405aa211012102cde27d7b29331ea3fef909a8d91f6f7753e99a3dd129914be50df26eed73fab3feffffff028447bf38000000001976a9146d7badec5426b880df25a3afc50e476c2423b34b88acb26b556a740000001976a914b3020d0ab85710151fa509d5d9a4e783903d681888ac83080a0018c7e1b3e505208391282884912832960112205068fb55e7a1fe5a12fa28ce313aecfc07c7de0e44aaeb6e72552f3b11142e9e1801226a47304402204034c3862f221551cffb2aa809f621f989a75cdb549c789a5ceb3a82c0bcc21c022001b4638f5d73fdd406a4dd9bf99be3dfca4a572b8f40f09b8fd495a7756c0db70121027a32ef45aef2f720ccf585f6fb0b8a7653db89cacc3320e5b385146851aba70528feffffff0f32940112201059a34bed569752ee98289177f04ec3b42a4b3bf2fc76687842c532ae40b2f3226a47304402204d442645597b13abb85e96e5acd34eff50a4418822fe6a37ed378cdd24574dff02205ae667c56eab63cc45a51063f15b72136fd76e97c46af29bd28e8c4d405aa211012102cde27d7b29331ea3fef909a8d91f6f7753e99a3dd129914be50df26eed73fab328feffffff0f3a450a0438bf47841a1976a9146d7badec5426b880df25a3afc50e476c2423b34b88ac2222524b4735747057776a6874716464546741335168556837516d4b637576426e6842583a480a05746a556bb210011a1976a914b3020d0ab85710151fa509d5d9a4e783903d681888ac222252526268564d624c6675657a485077554d756a546d4446417a76363459396d4a71644002" ) func init() { diff --git a/bchain/coins/ravencoin/ravencoinrpc.go b/bchain/coins/ravencoin/ravencoinrpc.go index c06bcc2ad0..6a168c1bc7 100644 --- a/bchain/coins/ravencoin/ravencoinrpc.go +++ b/bchain/coins/ravencoin/ravencoinrpc.go @@ -5,8 +5,8 @@ import ( "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // RavencoinRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/ritocoin/ritocoinparser.go b/bchain/coins/ritocoin/ritocoinparser.go index 420d80d828..ea1e3cfc30 100644 --- a/bchain/coins/ritocoin/ritocoinparser.go +++ b/bchain/coins/ritocoin/ritocoinparser.go @@ -5,9 +5,9 @@ import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) // magic numbers @@ -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/ritocoin/ritocoinparser_test.go b/bchain/coins/ritocoin/ritocoinparser_test.go index a592dce1f8..34d4b39ba2 100644 --- a/bchain/coins/ritocoin/ritocoinparser_test.go +++ b/bchain/coins/ritocoin/ritocoinparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { diff --git a/bchain/coins/ritocoin/ritocoinrpc.go b/bchain/coins/ritocoin/ritocoinrpc.go index 177b1d428b..3bac7a94ae 100644 --- a/bchain/coins/ritocoin/ritocoinrpc.go +++ b/bchain/coins/ritocoin/ritocoinrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // RitocoinRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/snowgem/snowgemparser.go b/bchain/coins/snowgem/snowgemparser.go new file mode 100644 index 0000000000..64534d1fb7 --- /dev/null +++ b/bchain/coins/snowgem/snowgemparser.go @@ -0,0 +1,98 @@ +package snowgem + +import ( + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + + "github.com/martinboehm/btcd/wire" + "github.com/martinboehm/btcutil/chaincfg" +) + +const ( + // MainnetMagic is mainnet network constant + MainnetMagic wire.BitcoinNet = 0x6427c824 + // TestnetMagic is testnet network constant + TestnetMagic wire.BitcoinNet = 0xbff91afa + // RegtestMagic is regtest network constant + RegtestMagic wire.BitcoinNet = 0x5f3fe8aa +) + +var ( + // MainNetParams are parser parameters for mainnet + MainNetParams chaincfg.Params + // TestNetParams are parser parameters for testnet + TestNetParams chaincfg.Params + // RegtestParams are parser parameters for regtest + RegtestParams chaincfg.Params +) + +func init() { + MainNetParams = chaincfg.MainNetParams + MainNetParams.Net = MainnetMagic + + // Address encoding magics + MainNetParams.AddressMagicLen = 2 + MainNetParams.PubKeyHashAddrID = []byte{0x1C, 0x28} // base58 prefix: s1 + MainNetParams.ScriptHashAddrID = []byte{0x1C, 0x2D} // base58 prefix: s3 + + TestNetParams = chaincfg.TestNet3Params + TestNetParams.Net = TestnetMagic + + // Address encoding magics + TestNetParams.AddressMagicLen = 2 + TestNetParams.PubKeyHashAddrID = []byte{0x1D, 0x25} // base58 prefix: tm + TestNetParams.ScriptHashAddrID = []byte{0x1C, 0xBA} // base58 prefix: t2 + + RegtestParams = chaincfg.RegressionNetParams + RegtestParams.Net = RegtestMagic +} + +// SnowGemParser handle +type SnowGemParser struct { + *btc.BitcoinLikeParser + baseparser *bchain.BaseParser +} + +// NewSnowGemParser returns new SnowGemParser instance +func NewSnowGemParser(params *chaincfg.Params, c *btc.Configuration) *SnowGemParser { + return &SnowGemParser{ + BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c), + baseparser: &bchain.BaseParser{}, + } +} + +// GetChainParams contains network parameters for the main SnowGem network, +// the regression test SnowGem network, the test SnowGem network and +// the simulation test SnowGem network, in this order +func GetChainParams(chain string) *chaincfg.Params { + if !chaincfg.IsRegistered(&MainNetParams) { + err := chaincfg.Register(&MainNetParams) + if err == nil { + err = chaincfg.Register(&TestNetParams) + } + if err == nil { + err = chaincfg.Register(&RegtestParams) + } + if err != nil { + panic(err) + } + } + switch chain { + case "test": + return &TestNetParams + case "regtest": + return &RegtestParams + default: + return &MainNetParams + } +} + +// PackTx packs transaction to byte array using protobuf +func (p *SnowGemParser) PackTx(tx *bchain.Tx, height uint32, blockTime int64) ([]byte, error) { + return p.baseparser.PackTx(tx, height, blockTime) +} + +// UnpackTx unpacks transaction from protobuf byte array +func (p *SnowGemParser) UnpackTx(buf []byte) (*bchain.Tx, uint32, error) { + return p.baseparser.UnpackTx(buf) +} diff --git a/bchain/coins/snowgem/snowgemparser_test.go b/bchain/coins/snowgem/snowgemparser_test.go new file mode 100644 index 0000000000..e6cac14357 --- /dev/null +++ b/bchain/coins/snowgem/snowgemparser_test.go @@ -0,0 +1,249 @@ +//go:build unittest + +package snowgem + +import ( + "bytes" + "encoding/hex" + "math/big" + "os" + "reflect" + "testing" + + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + + "github.com/martinboehm/btcutil/chaincfg" +) + +var ( + testTx1, testTx2 bchain.Tx + + testTxPacked1 = "0a20241803e368d7459f31286a155191ee386896d366d57c19d8e67a8f040d6ff71f12f4010400008085202f890119950c49d69b37d5f4fbb390d852387559e6a6d3fce9f390a409e4acf3f06381020000006a4730440220452aedf599e575598eb36d27ed98a6d388efda6e9be2bab96f16d0644e7df3060220669f4f3a4976ed73fa3ca9ecaad84dcf6ec35099c3bad631499985ea6a378d19012102ed9fb7fb61ec514be890ab45a925d554ff12050f099514251d5ebe904accc93ffeffffff02d3d0a146000000001976a9141a78c04d87f553545ba225b7bc7a271731f659d688ac7c54ae02000000001976a914b86f4b063545ebc2e80522a59d2dd206b707401b88aca68d0e00c58d0e00000000000000000000000018aba4b8ed0520a69b3a28b19b3a32960112208163f0f3ace409a490f3e9fcd3a6e659753852d890b3fbf4d5379bd6490c95191802226a4730440220452aedf599e575598eb36d27ed98a6d388efda6e9be2bab96f16d0644e7df3060220669f4f3a4976ed73fa3ca9ecaad84dcf6ec35099c3bad631499985ea6a378d19012102ed9fb7fb61ec514be890ab45a925d554ff12050f099514251d5ebe904accc93f28feffffff0f3a460a0446a1d0d31a1976a9141a78c04d87f553545ba225b7bc7a271731f659d688ac2223733150636953644665724a78665673397451353571446f3839695676466f7162436a7a3a480a0402ae547c10011a1976a914b86f4b063545ebc2e80522a59d2dd206b707401b88ac22237331653177736d6f7955625673794b726745374b73714c5164374c6975596168526152" + testTxPacked2 = "0a2071dd4d998b0a711fe5ed21f8661ed27ca8b99afc488f5bbe149ec3c6492ec50312d2010400008085202f89017308714b21338783a435c5e420542a0f6243da5be6dc8bdf19e2d526a318d6a8000000006a47304402207ce5ebcb2dc5e8027b5d672babd2e6aaa186a917caf2b44eec63f7db16277b8b02207a89214d825fae08ebc86bca1f46579e770e830bd31b8101498207a2d901fd74012103c3fe8969a7b08f1d586a68da70d6aeff61aa3b4cbe7ca2cb5aae11529ca2af12feffffff014dd45023000000001976a914cef34ec02e80351cf4f9d63843fc79a77c9ab71888acaa8d0e00c98d0e00000000000000000000000018f9a6b8ed0520aa9b3a28b59b3a3294011220a8d618a326d5e219df8bdce65bda43620f2a5420e4c535a4838733214b710873226a47304402207ce5ebcb2dc5e8027b5d672babd2e6aaa186a917caf2b44eec63f7db16277b8b02207a89214d825fae08ebc86bca1f46579e770e830bd31b8101498207a2d901fd74012103c3fe8969a7b08f1d586a68da70d6aeff61aa3b4cbe7ca2cb5aae11529ca2af1228feffffff0f3a460a042350d44d1a1976a914cef34ec02e80351cf4f9d63843fc79a77c9ab71888ac2223733167347a74585446447751326b506253385431666755334c645075666376354d764d" +) + +func init() { + testTx1 = bchain.Tx{ + Hex: "0400008085202f890119950c49d69b37d5f4fbb390d852387559e6a6d3fce9f390a409e4acf3f06381020000006a4730440220452aedf599e575598eb36d27ed98a6d388efda6e9be2bab96f16d0644e7df3060220669f4f3a4976ed73fa3ca9ecaad84dcf6ec35099c3bad631499985ea6a378d19012102ed9fb7fb61ec514be890ab45a925d554ff12050f099514251d5ebe904accc93ffeffffff02d3d0a146000000001976a9141a78c04d87f553545ba225b7bc7a271731f659d688ac7c54ae02000000001976a914b86f4b063545ebc2e80522a59d2dd206b707401b88aca68d0e00c58d0e000000000000000000000000", + Blocktime: 1571689003, + Time: 1571689003, + Txid: "241803e368d7459f31286a155191ee386896d366d57c19d8e67a8f040d6ff71f", + LockTime: 953766, + Vin: []bchain.Vin{ + { + ScriptSig: bchain.ScriptSig{ + Hex: "4730440220452aedf599e575598eb36d27ed98a6d388efda6e9be2bab96f16d0644e7df3060220669f4f3a4976ed73fa3ca9ecaad84dcf6ec35099c3bad631499985ea6a378d19012102ed9fb7fb61ec514be890ab45a925d554ff12050f099514251d5ebe904accc93f", + }, + Txid: "8163f0f3ace409a490f3e9fcd3a6e659753852d890b3fbf4d5379bd6490c9519", + Vout: 2, + Sequence: 4294967294, + }, + }, + Vout: []bchain.Vout{ + { + ValueSat: *big.NewInt(1185009875), + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a9141a78c04d87f553545ba225b7bc7a271731f659d688ac", + Addresses: []string{ + "s1PciSdFerJxfVs9tQ55qDo89iVvFoqbCjz", + }, + }, + }, + { + ValueSat: *big.NewInt(44979324), + N: 1, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a914b86f4b063545ebc2e80522a59d2dd206b707401b88ac", + Addresses: []string{ + "s1e1wsmoyUbVsyKrgE7KsqLQd7LiuYahRaR", + }, + }, + }, + }, + } + + testTx2 = bchain.Tx{ + Hex: "0400008085202f89017308714b21338783a435c5e420542a0f6243da5be6dc8bdf19e2d526a318d6a8000000006a47304402207ce5ebcb2dc5e8027b5d672babd2e6aaa186a917caf2b44eec63f7db16277b8b02207a89214d825fae08ebc86bca1f46579e770e830bd31b8101498207a2d901fd74012103c3fe8969a7b08f1d586a68da70d6aeff61aa3b4cbe7ca2cb5aae11529ca2af12feffffff014dd45023000000001976a914cef34ec02e80351cf4f9d63843fc79a77c9ab71888acaa8d0e00c98d0e000000000000000000000000", + Blocktime: 1571689337, + Time: 1571689337, + Txid: "71dd4d998b0a711fe5ed21f8661ed27ca8b99afc488f5bbe149ec3c6492ec503", + LockTime: 953770, + Vin: []bchain.Vin{ + { + ScriptSig: bchain.ScriptSig{ + Hex: "47304402207ce5ebcb2dc5e8027b5d672babd2e6aaa186a917caf2b44eec63f7db16277b8b02207a89214d825fae08ebc86bca1f46579e770e830bd31b8101498207a2d901fd74012103c3fe8969a7b08f1d586a68da70d6aeff61aa3b4cbe7ca2cb5aae11529ca2af12", + }, + Txid: "a8d618a326d5e219df8bdce65bda43620f2a5420e4c535a4838733214b710873", + Vout: 0, + Sequence: 4294967294, + }, + }, + Vout: []bchain.Vout{ + { + ValueSat: *big.NewInt(592499789), + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: "76a914cef34ec02e80351cf4f9d63843fc79a77c9ab71888ac", + Addresses: []string{ + "s1g4ztXTFDwQ2kPbS8T1fgU3LdPufcv5MvM", + }, + }, + }, + }, + } +} + +func TestMain(m *testing.M) { + c := m.Run() + chaincfg.ResetParams() + os.Exit(c) +} + +func TestGetAddrDesc(t *testing.T) { + type args struct { + tx bchain.Tx + parser *SnowGemParser + } + tests := []struct { + name string + args args + }{ + { + name: "snowgem-1", + args: args{ + tx: testTx1, + parser: NewSnowGemParser(GetChainParams("main"), &btc.Configuration{}), + }, + }, + { + name: "snowgem-2", + args: args{ + tx: testTx2, + parser: NewSnowGemParser(GetChainParams("main"), &btc.Configuration{}), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for n, vout := range tt.args.tx.Vout { + got1, err := tt.args.parser.GetAddrDescFromVout(&vout) + if err != nil { + t.Errorf("getAddrDescFromVout() error = %v, vout = %d", err, n) + return + } + got2, err := tt.args.parser.GetAddrDescFromAddress(vout.ScriptPubKey.Addresses[0]) + if err != nil { + t.Errorf("getAddrDescFromAddress() error = %v, vout = %d", err, n) + return + } + if !bytes.Equal(got1, got2) { + t.Errorf("Address descriptors mismatch: got1 = %v, got2 = %v", got1, got2) + } + } + }) + } +} + +func TestPackTx(t *testing.T) { + type args struct { + tx bchain.Tx + height uint32 + blockTime int64 + parser *SnowGemParser + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "snowgem-1", + args: args{ + tx: testTx1, + height: 953777, + blockTime: 1571689003, + parser: NewSnowGemParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: testTxPacked1, + wantErr: false, + }, + { + name: "snowgem-2", + args: args{ + tx: testTx2, + height: 953781, + blockTime: 1571689337, + parser: NewSnowGemParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: testTxPacked2, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.args.parser.PackTx(&tt.args.tx, tt.args.height, tt.args.blockTime) + if (err != nil) != tt.wantErr { + t.Errorf("packTx() error = %v, wantErr %v", err, tt.wantErr) + return + } + h := hex.EncodeToString(got) + if !reflect.DeepEqual(h, tt.want) { + t.Errorf("packTx() = %v, want %v", h, tt.want) + } + }) + } +} + +func TestUnpackTx(t *testing.T) { + type args struct { + packedTx string + parser *SnowGemParser + } + tests := []struct { + name string + args args + want *bchain.Tx + want1 uint32 + wantErr bool + }{ + { + name: "snowgem-1", + args: args{ + packedTx: testTxPacked1, + parser: NewSnowGemParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: &testTx1, + want1: 953777, + wantErr: false, + }, + { + name: "snowgem-2", + args: args{ + packedTx: testTxPacked2, + parser: NewSnowGemParser(GetChainParams("main"), &btc.Configuration{}), + }, + want: &testTx2, + want1: 953781, + wantErr: false, + }, + } + for _, tt := range tests { + 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 + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("unpackTx() got = %v, want %v", got, tt.want) + } + if got1 != tt.want1 { + t.Errorf("unpackTx() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} diff --git a/bchain/coins/snowgem/snowgemrpc.go b/bchain/coins/snowgem/snowgemrpc.go new file mode 100644 index 0000000000..9ef0221d60 --- /dev/null +++ b/bchain/coins/snowgem/snowgemrpc.go @@ -0,0 +1,116 @@ +package snowgem + +import ( + "encoding/json" + + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + + "github.com/golang/glog" + "github.com/juju/errors" +) + +// SnowGemRPC is an interface to JSON-RPC bitcoind service +type SnowGemRPC struct { + *btc.BitcoinRPC +} + +// NewSnowGemRPC returns new SnowGemRPC instance +func NewSnowGemRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { + b, err := btc.NewBitcoinRPC(config, pushHandler) + if err != nil { + return nil, err + } + z := &SnowGemRPC{ + BitcoinRPC: b.(*btc.BitcoinRPC), + } + z.RPCMarshaler = btc.JSONMarshalerV1{} + z.ChainConfig.SupportsEstimateSmartFee = false + return z, nil +} + +// Initialize initializes SnowGemRPC instance +func (z *SnowGemRPC) Initialize() error { + ci, err := z.GetChainInfo() + if err != nil { + return err + } + chainName := ci.Chain + + params := GetChainParams(chainName) + + z.Parser = NewSnowGemParser(params, z.ChainConfig) + + // parameters for getInfo request + if params.Net == MainnetMagic { + z.Testnet = false + z.Network = "livenet" + } else { + z.Testnet = true + z.Network = "testnet" + } + + glog.Info("rpc: block chain ", params.Name) + + return nil +} + +// GetBlock returns block with given hash. +func (z *SnowGemRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) { + var err error + if hash == "" && height > 0 { + hash, err = z.GetBlockHash(height) + if err != nil { + return nil, err + } + } + + glog.V(1).Info("rpc: getblock (verbosity=1) ", hash) + + res := btc.ResGetBlockThin{} + req := btc.CmdGetBlock{Method: "getblock"} + req.Params.BlockHash = hash + req.Params.Verbosity = 1 + err = z.Call(&req, &res) + + if err != nil { + return nil, errors.Annotatef(err, "hash %v", hash) + } + if res.Error != nil { + return nil, errors.Annotatef(res.Error, "hash %v", hash) + } + + txs := make([]bchain.Tx, 0, len(res.Result.Txids)) + for _, txid := range res.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 + } + txs = append(txs, *tx) + } + block := &bchain.Block{ + BlockHeader: res.Result.BlockHeader, + Txs: txs, + } + return block, nil +} + +// GetTransactionForMempool returns a transaction by the transaction ID. +// It could be optimized for mempool, i.e. without block time and confirmations +func (z *SnowGemRPC) GetTransactionForMempool(txid string) (*bchain.Tx, error) { + return z.GetTransaction(txid) +} + +// GetMempoolEntry returns mempool data for given transaction +func (z *SnowGemRPC) GetMempoolEntry(txid string) (*bchain.MempoolEntry, error) { + return nil, errors.New("GetMempoolEntry: not implemented") +} + +func isErrBlockNotFound(err *bchain.RPCError) bool { + return err.Message == "Block not found" || + err.Message == "Block height out of range" +} diff --git a/bchain/coins/sys/nevm.go b/bchain/coins/sys/nevm.go index ef0998ad33..c7ee7bc16b 100644 --- a/bchain/coins/sys/nevm.go +++ b/bchain/coins/sys/nevm.go @@ -1,31 +1,35 @@ package syscoin import ( - "context" - "math/big" - "strings" + "context" "fmt" + "math/big" + "strings" + "time" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" - "github.com/syscoin/blockbook/bchain" ethereum "github.com/ethereum/go-ethereum" - "github.com/syscoin/syscoinwire/syscoin/wire" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" "github.com/golang/glog" + "github.com/syscoin/syscoinwire/syscoin/wire" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) + const ( - vaultManagerAddress = "0x7904299b3D3dC1b03d1DdEb45E9fDF3576aCBd5f" - + vaultManagerAddress = "0x7904299b3D3dC1b03d1DdEb45E9fDF3576aCBd5f" + defaultNEVMRPCTimeoutSec = 15 ) + type NEVMClient struct { - rpcClient *ethclient.Client + rpcClient *ethclient.Client backupClient *ethclient.Client - vaultAddr common.Address - vaultABI abi.ABI - tokenABI abi.ABI - explorerURL string + vaultAddr common.Address + vaultABI abi.ABI + tokenABI abi.ABI + explorerURL string + timeout time.Duration } // NewNEVMClient initializes the primary and backup RPC clients. @@ -64,6 +68,10 @@ func NewNEVMClient(c *btc.Configuration) (*NEVMClient, error) { } return nil, err } + timeout := time.Duration(c.RPCTimeout) * time.Second + if timeout <= 0 { + timeout = defaultNEVMRPCTimeoutSec * time.Second + } return &NEVMClient{ rpcClient: mainClient, @@ -72,6 +80,7 @@ func NewNEVMClient(c *btc.Configuration) (*NEVMClient, error) { vaultABI: vaultABI, tokenABI: tokenABI, explorerURL: c.Web3Explorer, + timeout: timeout, }, nil } @@ -86,14 +95,18 @@ func (c *NEVMClient) Close() { } // callContract attempts the call with primary RPC, falling back to backup if needed. -func (c *NEVMClient) callContract(ctx context.Context, msg ethereum.CallMsg) ([]byte, error) { +func (c *NEVMClient) callContract(msg ethereum.CallMsg) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), c.timeout) res, err := c.rpcClient.CallContract(ctx, msg, nil) + cancel() if err == nil { return res, nil } // If backup RPC exists, attempt fallback if c.backupClient != nil { + ctx, cancel = context.WithTimeout(context.Background(), c.timeout) + defer cancel() return c.backupClient.CallContract(ctx, msg, nil) } @@ -108,7 +121,7 @@ func (c *NEVMClient) getRealTokenId(assetId uint32, tokenIdx uint32) (*big.Int, } callMsg := ethereum.CallMsg{To: &c.vaultAddr, Data: data} - res, err := c.callContract(context.Background(), callMsg) + res, err := c.callContract(callMsg) if err != nil { return nil, err } @@ -128,7 +141,7 @@ func (c *NEVMClient) getTokenSymbol(contractAddr common.Address) (string, error) } callMsg := ethereum.CallMsg{To: &contractAddr, Data: data} - res, err := c.callContract(context.Background(), callMsg) + res, err := c.callContract(callMsg) if err != nil { return "", err } @@ -155,8 +168,6 @@ func (c *NEVMClient) FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, err MetaData: []byte("Syscoin Native Asset"), }, nil } - - ctx := context.Background() assetId := uint32(assetGuid & 0xffffffff) tokenIdx := uint32(assetGuid >> 32) @@ -167,7 +178,7 @@ func (c *NEVMClient) FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, err } glog.Infof("Calling vaultManager for assetId: %d with data: %x", assetId, data) callMsg := ethereum.CallMsg{To: &c.vaultAddr, Data: data} - res, err := c.callContract(ctx, callMsg) + res, err := c.callContract(callMsg) if err != nil { return nil, err } @@ -194,6 +205,8 @@ func (c *NEVMClient) FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, err symbol = fmt.Sprintf("ERC20-%d", assetId) } metadata = "ERC20 Token" + // SYSCOIN: UTXO-side SPT values are stored and exposed as CAmount/COIN + // units, matching Core's 8-decimal asset_value formatting. precision = 8 case 3: // ERC721 (NFT) @@ -218,8 +231,7 @@ func (c *NEVMClient) FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, err precision = 0 default: - symbol = fmt.Sprintf("UNKNOWN-%d", assetId) - metadata = "Unknown Asset Type" + return nil, fmt.Errorf("unsupported NEVM asset type %d for asset %d", registry.AssetType, assetId) } return &bchain.Asset{ @@ -234,5 +246,3 @@ func (c *NEVMClient) FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, err MetaData: []byte(metadata), }, nil } - - diff --git a/bchain/coins/sys/syscoinparser.go b/bchain/coins/sys/syscoinparser.go index 1fd2e0b2f4..fc3b25c1d4 100644 --- a/bchain/coins/sys/syscoinparser.go +++ b/bchain/coins/sys/syscoinparser.go @@ -1,19 +1,21 @@ package syscoin import ( - "encoding/json" "bytes" + "encoding/hex" + "encoding/json" "math/big" - "github.com/martinboehm/btcd/wire" - "github.com/martinboehm/btcutil/chaincfg" - "github.com/martinboehm/btcutil/txscript" + vlq "github.com/bsm/go-vlq" "github.com/juju/errors" + "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil" - - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/martinboehm/btcutil/chaincfg" + "github.com/martinboehm/btcutil/txscript" + + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) // magic numbers @@ -23,13 +25,12 @@ const ( SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_SYSCOIN int32 = 138 SYSCOIN_TX_VERSION_SYSCOIN_BURN_TO_ALLOCATION int32 = 139 - SYSCOIN_TX_VERSION_ALLOCATION_MINT int32 = 140 - SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_NEVM int32 = 141 - SYSCOIN_TX_VERSION_ALLOCATION_SEND int32 = 142 + SYSCOIN_TX_VERSION_ALLOCATION_MINT int32 = 140 + SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_NEVM int32 = 141 + SYSCOIN_TX_VERSION_ALLOCATION_SEND int32 = 142 maxAddrDescLen = 10000 - maxMemoLen = 256 - + maxMemoLen = 256 ) // chain parameters @@ -44,14 +45,14 @@ func init() { // Mainnet address encoding magics MainNetParams.PubKeyHashAddrID = []byte{63} // base58 prefix: s - MainNetParams.ScriptHashAddrID = []byte{5} // base68 prefix: 3 + MainNetParams.ScriptHashAddrID = []byte{5} // base68 prefix: 3 MainNetParams.Bech32HRPSegwit = "sys" TestnetParams = chaincfg.TestNet3Params TestnetParams.Net = TestnetMagic // Testnet address encoding magics - TestnetParams.PubKeyHashAddrID = []byte{65} // base58 prefix: t + TestnetParams.PubKeyHashAddrID = []byte{65} // base58 prefix: t TestnetParams.ScriptHashAddrID = []byte{196} // base58 prefix: 2 TestnetParams.Bech32HRPSegwit = "tsys" } @@ -71,6 +72,12 @@ func NewSyscoinParser(params *chaincfg.Params, c *btc.Configuration) *SyscoinPar return parser } +// SupportsSyscoinAssets marks this Bitcoin-like parser as using the Syscoin SPT +// RocksDB extensions. +func (p *SyscoinParser) SupportsSyscoinAssets() bool { + return true +} + // matches max data carrier for systx func (p *SyscoinParser) GetMaxAddrLength() int { return maxAddrDescLen @@ -107,24 +114,53 @@ func (p *SyscoinParser) UnpackTx(buf []byte) (*bchain.Tx, uint32, error) { if err != nil { return nil, 0, err } - p.LoadAssets(tx) + if err := p.LoadAssets(tx); err != nil { + return nil, 0, err + } return tx, height, nil } -// TxFromMsgTx converts syscoin wire Tx to bchain.Tx -func (p *SyscoinParser) TxFromMsgTx(t *wire.MsgTx, parseAddresses bool) bchain.Tx { + +func (p *SyscoinParser) txFromMsgTx(t *wire.MsgTx, parseAddresses bool) (bchain.Tx, error) { tx := p.BitcoinLikeParser.TxFromMsgTx(t, parseAddresses) - p.LoadAssets(&tx) + if err := p.LoadAssets(&tx); err != nil { + return tx, err + } + return tx, nil +} + +// TxFromMsgTx converts syscoin wire Tx to bchain.Tx. +func (p *SyscoinParser) TxFromMsgTx(t *wire.MsgTx, parseAddresses bool) bchain.Tx { + tx, _ := p.txFromMsgTx(t, parseAddresses) return tx } + +// ParseTx parses byte array containing transaction and returns Tx struct. +func (p *SyscoinParser) ParseTx(b []byte) (*bchain.Tx, error) { + t := wire.MsgTx{} + r := bytes.NewReader(b) + if err := t.Deserialize(r); err != nil { + return nil, err + } + tx, err := p.txFromMsgTx(&t, true) + if err != nil { + return nil, err + } + tx.Hex = hex.EncodeToString(b) + return &tx, nil +} + // ParseTxFromJson parses JSON message containing transaction and returns Tx struct func (p *SyscoinParser) ParseTxFromJson(msg json.RawMessage) (*bchain.Tx, error) { tx, err := p.BaseParser.ParseTxFromJson(msg) if err != nil { return nil, err } - p.LoadAssets(tx) + if err := p.LoadAssets(tx); err != nil { + return nil, err + } return tx, nil } + // ParseBlock parses raw block to our Block struct // it has special handling for Auxpow blocks that cannot be parsed by standard btc wire parse func (p *SyscoinParser) ParseBlock(b []byte) (*bchain.Block, error) { @@ -149,11 +185,16 @@ func (p *SyscoinParser) ParseBlock(b []byte) (*bchain.Block, error) { txs := make([]bchain.Tx, len(w.Transactions)) for ti, t := range w.Transactions { - txs[ti] = p.TxFromMsgTx(t, false) + tx, err := p.txFromMsgTx(t, false) + if err != nil { + return nil, errors.Annotatef(err, "transaction %d", ti) + } + txs[ti] = tx } 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(), }, @@ -197,20 +238,19 @@ func (p *SyscoinParser) GetAssetsMaskFromVersion(nVersion int32) bchain.AssetsMa } func (p *SyscoinParser) IsSyscoinMintTx(nVersion int32) bool { - return nVersion == SYSCOIN_TX_VERSION_ALLOCATION_MINT + return nVersion == SYSCOIN_TX_VERSION_ALLOCATION_MINT } - // note assetsend in core is assettx but its deserialized as allocation, we just care about balances so we can do it in same code for allocations func (p *SyscoinParser) IsAssetAllocationTx(nVersion int32) bool { return nVersion == SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_NEVM || nVersion == SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_SYSCOIN || nVersion == SYSCOIN_TX_VERSION_SYSCOIN_BURN_TO_ALLOCATION || - nVersion == SYSCOIN_TX_VERSION_ALLOCATION_SEND + nVersion == SYSCOIN_TX_VERSION_ALLOCATION_SEND } func (p *SyscoinParser) IsSyscoinTx(nVersion int32, nHeight uint32) bool { - return p.IsAssetAllocationTx(nVersion) || p.IsSyscoinMintTx(nVersion) + return p.IsAssetAllocationTx(nVersion) || p.IsSyscoinMintTx(nVersion) } - + // TryGetOPReturn tries to process OP_RETURN script and return data func (p *SyscoinParser) TryGetOPReturn(script []byte) []byte { if len(script) > 1 && script[0] == txscript.OP_RETURN { @@ -221,10 +261,23 @@ func (p *SyscoinParser) TryGetOPReturn(script []byte) []byte { op := script[1] var data []byte if op < txscript.OP_PUSHDATA1 { + if len(script) < 2+int(op) { + return nil + } data = script[2:] } else if op == txscript.OP_PUSHDATA1 { + if len(script) < 3 || len(script) < 3+int(script[2]) { + return nil + } data = script[3:] } else if op == txscript.OP_PUSHDATA2 { + if len(script) < 4 { + return nil + } + pushLen := int(script[2]) | int(script[3])<<8 + if len(script) < 4+pushLen { + return nil + } data = script[4:] } return data @@ -258,8 +311,6 @@ func (p *SyscoinParser) GetSPTDataFromDesc(addrDesc *bchain.AddressDescriptor) ( return sptData, nil } - - func (p *SyscoinParser) GetAssetAllocationFromDesc(addrDesc *bchain.AddressDescriptor, txVersion int32) (*bchain.AssetAllocation, []byte, error) { sptData, err := p.GetSPTDataFromDesc(addrDesc) if err != nil { @@ -276,7 +327,7 @@ func (p *SyscoinParser) GetAssetAllocationFromData(sptData []byte, txVersion int return nil, nil, err } var memo []byte - if (p.IsAssetAllocationTx(txVersion) && txVersion != SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_NEVM && txVersion != SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_SYSCOIN) { + if p.IsAssetAllocationTx(txVersion) && txVersion != SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_NEVM && txVersion != SYSCOIN_TX_VERSION_ALLOCATION_BURN_TO_SYSCOIN { memo = make([]byte, maxMemoLen) n, _ := r.Read(memo) memo = memo[:n] @@ -284,18 +335,21 @@ func (p *SyscoinParser) GetAssetAllocationFromData(sptData []byte, txVersion int return &assetAllocation, memo, nil } func (p *SyscoinParser) LoadAssets(tx *bchain.Tx) error { - if p.IsSyscoinTx(tx.Version, tx.BlockHeight) { - allocation, memo, err := p.GetAllocationFromTx(tx) + if p.IsSyscoinTx(tx.Version, tx.BlockHeight) { + allocation, memo, err := p.GetAllocationFromTx(tx) if err != nil { return err } tx.Memo = memo - for _, v := range allocation.AssetObj.VoutAssets { - for _,voutAsset := range v.Values { + for _, v := range allocation.AssetObj.VoutAssets { + for _, voutAsset := range v.Values { + if int(voutAsset.N) >= len(tx.Vout) { + return errors.Errorf("asset allocation vout index %d out of range", voutAsset.N) + } // store in vout tx.Vout[voutAsset.N].AssetInfo = &bchain.AssetInfo{AssetGuid: v.AssetGuid, ValueSat: big.NewInt(voutAsset.ValueSat)} - } - } + } + } } return nil } @@ -308,7 +362,6 @@ func (p *SyscoinParser) WitnessPubKeyHashFromKeyID(keyId []byte) (string, error) return addr.EncodeAddress(), nil } - func (p *SyscoinParser) PackAssetKey(assetGuid uint64, height uint32) []byte { var buf []byte varBuf := make([]byte, vlq.MaxLen64) @@ -375,6 +428,52 @@ func (p *SyscoinParser) UnpackAssetInfo(assetInfo *bchain.AssetInfo, buf []byte) return l } +func (p *SyscoinParser) appendTxInput(txi *bchain.TxInput, buf []byte, varBuf []byte) []byte { + l := p.BaseParser.PackVaruint(uint(len(txi.AddrDesc)), varBuf) + buf = append(buf, varBuf[:l]...) + buf = append(buf, txi.AddrDesc...) + l = p.BaseParser.PackBigint(&txi.ValueSat, varBuf) + buf = append(buf, varBuf[:l]...) + return buf +} + +func (p *SyscoinParser) appendTxOutput(txo *bchain.TxOutput, buf []byte, varBuf []byte) []byte { + addrDescLen := len(txo.AddrDesc) + if txo.Spent { + addrDescLen = ^addrDescLen + } + l := vlq.PutInt(varBuf, int64(addrDescLen)) + buf = append(buf, varBuf[:l]...) + buf = append(buf, txo.AddrDesc...) + l = p.BaseParser.PackBigint(&txo.ValueSat, varBuf) + buf = append(buf, varBuf[:l]...) + return buf +} + +func (p *SyscoinParser) unpackTxInput(ti *bchain.TxInput, buf []byte) int { + addrDescLen, l := p.BaseParser.UnpackVaruint(buf) + offset := l + ti.AddrDesc = append([]byte(nil), buf[offset:offset+int(addrDescLen)]...) + offset += int(addrDescLen) + var valueLen int + ti.ValueSat, valueLen = p.BaseParser.UnpackBigint(buf[offset:]) + return offset + valueLen +} + +func (p *SyscoinParser) unpackTxOutput(to *bchain.TxOutput, buf []byte) int { + addrDescLen, l := vlq.Int(buf) + if addrDescLen < 0 { + to.Spent = true + addrDescLen = ^addrDescLen + } + offset := l + to.AddrDesc = append([]byte(nil), buf[offset:offset+int(addrDescLen)]...) + offset += int(addrDescLen) + var valueLen int + to.ValueSat, valueLen = p.BaseParser.UnpackBigint(buf[offset:]) + return offset + valueLen +} + func (p *SyscoinParser) PackTxAddresses(ta *bchain.TxAddresses, buf []byte, varBuf []byte) []byte { buf = buf[:0] // pack version info for syscoin to detect sysx tx types @@ -386,7 +485,7 @@ func (p *SyscoinParser) PackTxAddresses(ta *bchain.TxAddresses, buf []byte, varB buf = append(buf, varBuf[:l]...) for i := range ta.Inputs { ti := &ta.Inputs[i] - buf = p.BitcoinLikeParser.AppendTxInput(ti, buf, varBuf) + buf = p.appendTxInput(ti, buf, varBuf) if ti.AssetInfo != nil { l = p.BaseParser.PackVaruint(1, varBuf) buf = append(buf, varBuf[:l]...) @@ -400,7 +499,7 @@ func (p *SyscoinParser) PackTxAddresses(ta *bchain.TxAddresses, buf []byte, varB buf = append(buf, varBuf[:l]...) for i := range ta.Outputs { to := &ta.Outputs[i] - buf = p.BitcoinLikeParser.AppendTxOutput(to, buf, varBuf) + buf = p.appendTxOutput(to, buf, varBuf) if to.AssetInfo != nil { l = p.BaseParser.PackVaruint(1, varBuf) buf = append(buf, varBuf[:l]...) @@ -427,7 +526,7 @@ func (p *SyscoinParser) UnpackTxAddresses(buf []byte) (*bchain.TxAddresses, erro ta.Inputs = make([]bchain.TxInput, inputs) for i := uint(0); i < inputs; i++ { ti := &ta.Inputs[i] - l += p.BitcoinLikeParser.UnpackTxInput(ti, buf[l:]) + l += p.unpackTxInput(ti, buf[l:]) assetInfoFlag, ll := p.BaseParser.UnpackVaruint(buf[l:]) l += ll if assetInfoFlag == 1 { @@ -440,7 +539,7 @@ func (p *SyscoinParser) UnpackTxAddresses(buf []byte) (*bchain.TxAddresses, erro ta.Outputs = make([]bchain.TxOutput, outputs) for i := uint(0); i < outputs; i++ { to := &ta.Outputs[i] - l += p.BitcoinLikeParser.UnpackTxOutput(to, buf[l:]) + l += p.unpackTxOutput(to, buf[l:]) assetInfoFlag, ll := p.BaseParser.UnpackVaruint(buf[l:]) l += ll if assetInfoFlag == 1 { @@ -522,7 +621,7 @@ func (p *SyscoinParser) PackAddrBalance(ab *bchain.AddrBalance, buf, varBuf []by buf = append(buf, varBuf[:l]...) l = p.BaseParser.PackBigint(&ab.BalanceSat, varBuf) buf = append(buf, varBuf[:l]...) - + // pack asset balance information l = p.BaseParser.PackVaruint(uint(len(ab.AssetBalances)), varBuf) buf = append(buf, varBuf[:l]...) @@ -609,7 +708,7 @@ func (p *SyscoinParser) PackTxIndexes(txi []bchain.TxIndexes) []byte { func (p *SyscoinParser) PackAsset(asset *bchain.Asset) ([]byte, error) { buf := make([]byte, 0, 315) - varBuf := make([]byte, 4) + varBuf := make([]byte, vlq.MaxLen64) l := p.BaseParser.PackVaruint(uint(asset.Transactions), varBuf) buf = append(buf, varBuf[:l]...) buf = append(buf, p.BaseParser.PackVarBytes(asset.MetaData)...) @@ -635,4 +734,4 @@ func (p *SyscoinParser) UnpackAsset(buf []byte) (*bchain.Asset, error) { return nil, err } return &asset, nil -} \ No newline at end of file +} diff --git a/bchain/coins/sys/syscoinparser_test.go b/bchain/coins/sys/syscoinparser_test.go index 26885900c9..ad2d4d7d96 100644 --- a/bchain/coins/sys/syscoinparser_test.go +++ b/bchain/coins/sys/syscoinparser_test.go @@ -3,6 +3,7 @@ package syscoin import ( + "bytes" "encoding/hex" "math/big" "os" @@ -10,8 +11,9 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + syscoinwire "github.com/syscoin/syscoinwire/syscoin/wire" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -217,6 +219,68 @@ func Test_PackTx(t *testing.T) { } } +func Test_ParseTxAssetVersionPropagatesLoadAssetsError(t *testing.T) { + parser := NewSyscoinParser(GetChainParams("main"), &btc.Configuration{}) + raw, err := hex.DecodeString(testTx1.Hex) + if err != nil { + t.Fatal(err) + } + // SYSCOIN: asset allocation versions must include a decodable allocation + // OP_RETURN. This otherwise valid tx has no allocation payload. + raw[0] = byte(SYSCOIN_TX_VERSION_ALLOCATION_SEND) + raw[1] = 0 + raw[2] = 0 + raw[3] = 0 + + if _, err := parser.ParseTx(raw); err == nil { + t.Fatal("ParseTx returned nil error for malformed asset allocation tx") + } +} + +func Test_TryGetOPReturnRejectsMalformedPushdata(t *testing.T) { + parser := NewSyscoinParser(GetChainParams("main"), &btc.Configuration{}) + tests := [][]byte{ + {0x6a, 0x02, 0x01}, + {0x6a, 0x4c}, + {0x6a, 0x4c, 0x02, 0x01}, + {0x6a, 0x4d, 0x00}, + {0x6a, 0x4d, 0x02, 0x00, 0x01}, + } + for _, script := range tests { + if got := parser.TryGetOPReturn(script); got != nil { + t.Fatalf("TryGetOPReturn(%x) = %x, want nil", script, got) + } + } +} + +func Test_LoadAssetsRejectsOutOfRangeVoutIndex(t *testing.T) { + parser := NewSyscoinParser(GetChainParams("main"), &btc.Configuration{}) + allocation := syscoinwire.AssetAllocationType{ + VoutAssets: []syscoinwire.AssetOutType{{ + AssetGuid: 123456, + Values: []syscoinwire.AssetOutValueType{{N: 1, ValueSat: 100}}, + }}, + } + var payload bytes.Buffer + if err := allocation.Serialize(&payload); err != nil { + t.Fatal(err) + } + script := append([]byte{0x6a, byte(payload.Len())}, payload.Bytes()...) + tx := &bchain.Tx{ + Version: SYSCOIN_TX_VERSION_ALLOCATION_SEND, + Vout: []bchain.Vout{{ + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: hex.EncodeToString(script), + }, + }}, + } + + if err := parser.LoadAssets(tx); err == nil { + t.Fatal("LoadAssets returned nil error for out-of-range allocation vout index") + } +} + func Test_UnpackTx(t *testing.T) { type args struct { packedTx string @@ -248,6 +312,12 @@ func Test_UnpackTx(t *testing.T) { t.Errorf("unpackTx() error = %v, wantErr %v", err, tt.wantErr) return } + if len(got.Vin) > 0 && len(got.Vin[0].Witness) == 0 { + t.Errorf("unpackTx() expected segwit witness data") + } + 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/sys/syscoinrpc.go b/bchain/coins/sys/syscoinrpc.go index 566a33d466..c341c16eb6 100644 --- a/bchain/coins/sys/syscoinrpc.go +++ b/bchain/coins/sys/syscoinrpc.go @@ -1,12 +1,12 @@ package syscoin import ( - "encoding/json" "context" - + "encoding/json" + "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // SyscoinRPC is an interface to JSON-RPC bitcoind service @@ -15,7 +15,6 @@ type SyscoinRPC struct { NEVMClient *NEVMClient } - // NewSyscoinRPC returns new SyscoinRPC instance func NewSyscoinRPC(config json.RawMessage, pushHandler func(notificationType bchain.NotificationType)) (bchain.BlockChain, error) { b, err := btc.NewBitcoinRPC(config, pushHandler) @@ -48,7 +47,6 @@ func (b *SyscoinRPC) Shutdown(ctx context.Context) error { return nil } - // Initialize initializes SyscoinRPC instance. func (b *SyscoinRPC) Initialize() error { ci, err := b.GetChainInfo() @@ -84,43 +82,8 @@ func (b *SyscoinRPC) FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, err func (b *SyscoinRPC) GetContractExplorerBaseURL() string { return b.ChainConfig.Web3Explorer } -// GetBlock returns block with given hash -func (b *SyscoinRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) { - var err error - if hash == "" { - hash, err = b.GetBlockHash(height) - if err != nil { - return nil, err - } - } - if !b.ParseBlocks { - return b.GetBlockFull(hash) - } - return b.GetBlockWithoutHeader(hash, height) -} - -func (b *SyscoinRPC) GetChainTips() (string, error) { - glog.V(1).Info("rpc: getchaintips") - - res := btc.ResGetChainTips{} - req := btc.CmdGetChainTips{Method: "getchaintips"} - err := b.Call(&req, &res) - - if err != nil { - return "", err - } - if res.Error != nil { - return "", err - } - rawMarshal, err := json.Marshal(&res.Result) - if err != nil { - return "", err - } - decodedRawString := string(rawMarshal) - return decodedRawString, nil -} -func (b *SyscoinRPC) GetSPVProof(hash string) (string, error) { +func (b *SyscoinRPC) GetSPVProof(hash string) (json.RawMessage, error) { glog.V(1).Info("rpc: getspvproof", hash) res := btc.ResGetSPVProof{} @@ -129,17 +92,12 @@ func (b *SyscoinRPC) GetSPVProof(hash string) (string, error) { err := b.Call(&req, &res) if err != nil { - return "", err + return nil, err } if res.Error != nil { - return "", err + return nil, res.Error } - rawMarshal, err := json.Marshal(&res.Result) - if err != nil { - return "", err - } - decodedRawString := string(rawMarshal) - return decodedRawString, nil + return res.Result, nil } // GetTransactionForMempool returns a transaction by the transaction ID. @@ -148,12 +106,10 @@ func (b *SyscoinRPC) GetTransactionForMempool(txid string) (*bchain.Tx, error) { return b.GetTransaction(txid) } -// Syscoin Core sendrawtransaction default maxfeerate is 0.10. -// Syscoin Core sendrawtransaction default maxburnamount is 0.0. -// Governance Proposal needs maxburnamount of 150. -// This change allows sending governance proposals without explicitly setting both parameters. +// SYSCOIN: Syscoin Core sendrawtransaction default maxburnamount is 0.0. +// Governance proposal collateral needs 150 SYS burn allowance. const ( - defaultSyscoinMaxFeeRate = "0.10" + defaultSyscoinMaxFeeRate = "0.10" defaultSyscoinMaxBurnAmount = "150" ) @@ -169,9 +125,10 @@ func syscoinSendRawParams(p bchain.SendRawTransactionParams) bchain.SendRawTrans return p } -// Override BitcoinRPC to apply Syscoin default maxfeerate / maxburnamount. -func (b *SyscoinRPC) SendRawTransaction(tx string) (string, error) { - return b.SendRawTransactionWithOpts(bchain.SendRawTransactionParams{Hex: tx}) +// SendRawTransaction overrides BitcoinRPC to apply Syscoin default maxfeerate / +// maxburnamount while preserving upstream's disableAlternativeRPC argument. +func (b *SyscoinRPC) SendRawTransaction(tx string, disableAlternativeRPC bool) (string, error) { + return b.SendRawTransactionWithOpts(bchain.SendRawTransactionParams{Hex: tx, DisableAlternativeRPC: disableAlternativeRPC}) } // Forwards maxfeerate / maxburnamount to Syscoin Core sendrawtransaction. @@ -180,4 +137,4 @@ func (b *SyscoinRPC) SendRawTransactionWithOpts(p bchain.SendRawTransactionParam return btc.SendRawTransactionWithParams(b.BitcoinRPC, p) } -var _ bchain.SendRawTransactionOpts = (*SyscoinRPC)(nil) \ No newline at end of file +var _ bchain.SendRawTransactionOpts = (*SyscoinRPC)(nil) diff --git a/bchain/coins/trezarcoin/trezarcoinparser.go b/bchain/coins/trezarcoin/trezarcoinparser.go new file mode 100644 index 0000000000..f5f18a87b1 --- /dev/null +++ b/bchain/coins/trezarcoin/trezarcoinparser.go @@ -0,0 +1,63 @@ +package trezarcoin + +import ( + "github.com/martinboehm/btcd/wire" + "github.com/martinboehm/btcutil/chaincfg" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" +) + +// magic numbers +const ( + MainnetMagic wire.BitcoinNet = 0xfddbefe4 +) + +// chain parameters +var ( + MainNetParams chaincfg.Params +) + +func init() { + MainNetParams = chaincfg.MainNetParams + MainNetParams.Net = MainnetMagic + MainNetParams.PubKeyHashAddrID = []byte{66} + MainNetParams.ScriptHashAddrID = []byte{8} +} + +// TrezarcoinParser handle +type TrezarcoinParser struct { + *btc.BitcoinLikeParser + baseparser *bchain.BaseParser +} + +// NewTrezarcoinParser returns new TrezarcoinParser instance +func NewTrezarcoinParser(params *chaincfg.Params, c *btc.Configuration) *TrezarcoinParser { + return &TrezarcoinParser{ + BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c), + baseparser: &bchain.BaseParser{}, + } +} + +// GetChainParams returns network parameters +func GetChainParams(chain string) *chaincfg.Params { + if !chaincfg.IsRegistered(&MainNetParams) { + err := chaincfg.Register(&MainNetParams) + if err != nil { + panic(err) + } + } + switch chain { + default: + return &MainNetParams + } +} + +// PackTx packs transaction to byte array using protobuf +func (p *TrezarcoinParser) PackTx(tx *bchain.Tx, height uint32, blockTime int64) ([]byte, error) { + return p.baseparser.PackTx(tx, height, blockTime) +} + +// UnpackTx unpacks transaction from protobuf byte array +func (p *TrezarcoinParser) UnpackTx(buf []byte) (*bchain.Tx, uint32, error) { + return p.baseparser.UnpackTx(buf) +} diff --git a/bchain/coins/trezarcoin/trezarcoinparser_test.go b/bchain/coins/trezarcoin/trezarcoinparser_test.go new file mode 100644 index 0000000000..70bb3a5bda --- /dev/null +++ b/bchain/coins/trezarcoin/trezarcoinparser_test.go @@ -0,0 +1,71 @@ +//go:build unittest + +package trezarcoin + +import ( + "encoding/hex" + "os" + "reflect" + "testing" + + "github.com/martinboehm/btcutil/chaincfg" + "github.com/trezor/blockbook/bchain/coins/btc" +) + +func TestMain(m *testing.M) { + c := m.Run() + chaincfg.ResetParams() + os.Exit(c) +} + +func Test_GetAddrDescFromAddress_Mainnet(t *testing.T) { + type args struct { + address string + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "pubkeyhash1", + args: args{address: "TovkYkEtp73t4KYEJxMxXhBMKVdDPmr7Hv"}, + want: "76a914a05ddd8e3268846a3e7a4ddf505adb942cc6557488ac", + wantErr: false, + }, + { + name: "pubkeyhash2", + args: args{address: "TuEoL199onxdg7z69D12AmhMnEEAH742Ro"}, + want: "76a914daa04d741763566e77a9df316f6cf755e8e77d3088ac", + wantErr: false, + }, + { + name: "scripthash1", + args: args{address: "4Nx2k3S57z4PbUoP9M6BpQBCpizn8critB"}, + want: "a9146568dc26eb0054c19042114cae9cff56e816a06c87", + wantErr: false, + }, + { + name: "scripthash2", + args: args{address: "4XvMi1G8rXtgZnz5G8S9yA3QzTtaC8TLrY"}, + want: "a914c7d0fdbdc654f7154b014f83b9d607f3adfbf4f887", + wantErr: false, + }, + } + parser := NewTrezarcoinParser(GetChainParams("main"), &btc.Configuration{}) + + 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 !reflect.DeepEqual(h, tt.want) { + t.Errorf("GetAddrDescFromAddress() = %v, want %v", h, tt.want) + } + }) + } +} diff --git a/bchain/coins/trezarcoin/trezarcoinrpc.go b/bchain/coins/trezarcoin/trezarcoinrpc.go new file mode 100644 index 0000000000..b011b33bad --- /dev/null +++ b/bchain/coins/trezarcoin/trezarcoinrpc.go @@ -0,0 +1,114 @@ +package trezarcoin + +import ( + "encoding/json" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" +) + +// TrezarcoinRPC is an interface to JSON-RPC bitcoind service. +type TrezarcoinRPC struct { + *btc.BitcoinRPC +} + +// NewTrezarcoinRPC returns new TrezarcoinRPC instance. +func NewTrezarcoinRPC(config json.RawMessage, pushHandler func(bchain.NotificationType)) (bchain.BlockChain, error) { + b, err := btc.NewBitcoinRPC(config, pushHandler) + if err != nil { + return nil, err + } + + s := &TrezarcoinRPC{ + b.(*btc.BitcoinRPC), + } + s.RPCMarshaler = btc.JSONMarshalerV1{} + s.ChainConfig.SupportsEstimateFee = false + + return s, nil +} + +// Initialize initializes TrezarcoinRPC instance. +func (b *TrezarcoinRPC) Initialize() error { + ci, err := b.GetChainInfo() + if err != nil { + return err + } + chainName := ci.Chain + + glog.Info("Chain name ", chainName) + params := GetChainParams(chainName) + + // always create parser + b.Parser = NewTrezarcoinParser(params, b.ChainConfig) + + // parameters for getInfo request + b.Testnet = false + b.Network = "livenet" + + glog.Info("rpc: block chain ", params.Name) + + return nil +} + +// GetBlock returns block with given hash. +func (f *TrezarcoinRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) { + var err error + if hash == "" && height > 0 { + hash, err = f.GetBlockHash(height) + if err != nil { + return nil, err + } + } + + glog.V(1).Info("rpc: getblock (verbosity=1) ", hash) + + res := btc.ResGetBlockThin{} + req := btc.CmdGetBlock{Method: "getblock"} + req.Params.BlockHash = hash + req.Params.Verbosity = 1 + err = f.Call(&req, &res) + + if err != nil { + return nil, errors.Annotatef(err, "hash %v", hash) + } + if res.Error != nil { + return nil, errors.Annotatef(res.Error, "hash %v", hash) + } + + txs := make([]bchain.Tx, 0, len(res.Result.Txids)) + for _, txid := range res.Result.Txids { + tx, err := f.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 + } + txs = append(txs, *tx) + } + block := &bchain.Block{ + BlockHeader: res.Result.BlockHeader, + Txs: txs, + } + return block, nil +} + +// GetTransactionForMempool returns a transaction by the transaction ID. +// It could be optimized for mempool, i.e. without block time and confirmations +func (f *TrezarcoinRPC) GetTransactionForMempool(txid string) (*bchain.Tx, error) { + return f.GetTransaction(txid) +} + +// GetMempoolEntry returns mempool data for given transaction +func (f *TrezarcoinRPC) GetMempoolEntry(txid string) (*bchain.MempoolEntry, error) { + return nil, errors.New("GetMempoolEntry: not implemented") +} + +func isErrBlockNotFound(err *bchain.RPCError) bool { + return err.Message == "Block not found" || + err.Message == "Block height out of range" +} 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..73ba3d33f5 --- /dev/null +++ b/bchain/coins/tron/normalization.go @@ -0,0 +1,172 @@ +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) + if l.Data == "" { + // Tron omits empty log data; the Ethereum tx cache packer expects a hex string. + l.Data = "0x" + } + 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..bf222f055e --- /dev/null +++ b/bchain/coins/tron/tronInternalDataProvider_test.go @@ -0,0 +1,293 @@ +//go:build unittest + +package tron + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/trezor/blockbook/bchain" +) + +type MockTronHTTPClient struct { + Resp interface{} + RespByPath map[string]interface{} + ErrByPath map[string]error + Err error + + mu sync.RWMutex + + LastPath string + LastBody interface{} + Paths []string + Bodies []interface{} +} + +func (m *MockTronHTTPClient) Request(ctx context.Context, path string, reqBody interface{}, respBody interface{}) error { + m.mu.Lock() + m.LastPath = path + m.LastBody = reqBody + m.Paths = append(m.Paths, path) + m.Bodies = append(m.Bodies, reqBody) + m.mu.Unlock() + + if m.ErrByPath != nil { + if err, ok := m.ErrByPath[path]; ok { + return err + } + } + if m.Err != nil { + return m.Err + } + resp := m.Resp + if m.RespByPath != nil { + if v, ok := m.RespByPath[path]; ok { + resp = v + } + } + b, _ := json.Marshal(resp) + return json.Unmarshal(b, respBody) +} + +func (m *MockTronHTTPClient) SnapshotLastRequest() (string, interface{}) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.LastPath, m.LastBody +} + +func (m *MockTronHTTPClient) SnapshotRequests() ([]string, []interface{}) { + m.mu.RLock() + defer m.mu.RUnlock() + + paths := append([]string(nil), m.Paths...) + bodies := append([]interface{}(nil), m.Bodies...) + return paths, bodies +} + +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 + lastPath, lastBody := mockHTTP.SnapshotLastRequest() + require.Equal(t, "/walletsolidity/gettransactioninfobyblocknum", lastPath) + require.Equal(t, map[string]any{"num": uint32(99)}, 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..0e117033fe --- /dev/null +++ b/bchain/coins/tron/tronhttp_endpoints.go @@ -0,0 +1,517 @@ +package tron + +import ( + "bytes" + "context" + "encoding/json" + "math/big" + "strconv" + + "github.com/golang/glog" + "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"` + TotalNetLimit int64 `json:"TotalNetLimit"` + TotalNetWeight int64 `json:"TotalNetWeight"` + EnergyLimit int64 `json:"EnergyLimit"` + EnergyUsed int64 `json:"EnergyUsed"` + TotalEnergyLimit int64 `json:"TotalEnergyLimit"` + TotalEnergyWeight int64 `json:"TotalEnergyWeight"` + TronPowerUsed int64 `json:"tronPowerUsed"` + TronPowerLimit int64 `json:"tronPowerLimit"` +} + +type tronFrozenV2Entry struct { + Type *tronResourceCode `json:"type,omitempty"` + Amount *int64 `json:"amount,omitempty"` +} + +type tronUnfrozenV2Entry struct { + UnfreezeAmount *int64 `json:"unfreeze_amount,omitempty"` + UnfreezeExpireTime *int64 `json:"unfreeze_expire_time,omitempty"` +} + +type tronGetAccountResponse struct { + Address string `json:"address,omitempty"` + FrozenV2 []tronFrozenV2Entry `json:"frozenV2,omitempty"` + UnfrozenV2 []tronUnfrozenV2Entry `json:"unfrozenV2,omitempty"` + Votes []tronTxVote `json:"votes,omitempty"` + AccountResource struct { + DelegatedFrozenV2BalanceForEnergy int64 `json:"delegated_frozenV2_balance_for_energy"` + } `json:"account_resource,omitempty"` + DelegatedFrozenV2BalanceForBandwidth int64 `json:"delegated_frozenV2_balance_for_bandwidth"` +} + +type tronGetRewardResponse struct { + Reward int64 `json:"reward"` +} + +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(b.requestContext(), b.Timeout) + defer cancel() + + return b.requestTransactionByID(ctx, txid, isSolidified) +} + +func (b *TronRPC) getTransactionInfoByID(txid string, isSolidified bool) (*tronGetTransactionInfoByIDResponse, error) { + ctx, cancel := context.WithTimeout(b.requestContext(), b.Timeout) + defer cancel() + + return b.requestTransactionInfoByID(ctx, txid, isSolidified) +} + +func (b *TronRPC) GetMempoolTransactions() ([]string, error) { + ctx, cancel := context.WithTimeout(b.requestContext(), 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(b.requestContext(), b.Timeout) + defer cancel() + + address := ToTronAddressFromDesc(addrDesc) + type accountResourceResult struct { + resp *tronGetAccountResourceResponse + err error + } + type accountResult struct { + resp *tronGetAccountResponse + err error + } + type rewardResult struct { + resp *tronGetRewardResponse + err error + } + resourceCh := make(chan accountResourceResult, 1) + accountCh := make(chan accountResult, 1) + rewardCh := make(chan rewardResult, 1) + + go func() { + resp, err := b.requestAccountResource(ctx, address) + resourceCh <- accountResourceResult{resp: resp, err: err} + }() + go func() { + resp, err := b.requestAccount(ctx, address) + accountCh <- accountResult{resp: resp, err: err} + }() + go func() { + resp, err := b.requestReward(ctx, address) + rewardCh <- rewardResult{resp: resp, err: err} + }() + + resourceRes := <-resourceCh + if resourceRes.err != nil { + cancel() + return nil, resourceRes.err + } + accountRes := <-accountCh + + var stakingInfo *bchain.TronStakingInfo + if accountRes.err != nil { + // Keep resource fields available even when staking/governance endpoints are temporarily unavailable. + glog.Warningf("Tron /wallet/getaccount failed for %s: %v", address, accountRes.err) + // No staking data can be built without /wallet/getaccount, do not wait for /wallet/getReward. + cancel() + } else if accountRes.resp.Address == "" { + // The Tron node returns {} (no address field) for non-existent accounts. + cancel() + } else { + rewardRes := <-rewardCh + rewardResp := rewardRes.resp + if rewardRes.err != nil { + glog.Warningf("Tron /wallet/getReward failed for %s: %v", address, rewardRes.err) + rewardResp = &tronGetRewardResponse{} + } + stakingInfo = tronBuildStakingInfo(accountRes.resp, resourceRes.resp, rewardResp) + } + + payload, err := json.Marshal(bchain.TronAccountExtraData{ + AvailableStakedBandwidth: tronAvailableResource(resourceRes.resp.NetLimit, resourceRes.resp.NetUsed), + TotalStakedBandwidth: resourceRes.resp.NetLimit, + AvailableFreeBandwidth: tronAvailableResource(resourceRes.resp.FreeNetLimit, resourceRes.resp.FreeNetUsed), + TotalFreeBandwidth: resourceRes.resp.FreeNetLimit, + AvailableEnergy: tronAvailableResource(resourceRes.resp.EnergyLimit, resourceRes.resp.EnergyUsed), + TotalEnergy: resourceRes.resp.EnergyLimit, + TotalEnergyLimit: resourceRes.resp.TotalEnergyLimit, + TotalEnergyWeight: resourceRes.resp.TotalEnergyWeight, + TotalBandwidthLimit: resourceRes.resp.TotalNetLimit, + TotalBandwidthWeight: resourceRes.resp.TotalNetWeight, + StakingInfo: stakingInfo, + }) + if err != nil { + return nil, err + } + return payload, nil +} + +func tronBuildStakingInfo(accountResp *tronGetAccountResponse, resourceResp *tronGetAccountResourceResponse, rewardResp *tronGetRewardResponse) *bchain.TronStakingInfo { + if accountResp == nil { + accountResp = &tronGetAccountResponse{} + } + if resourceResp == nil { + resourceResp = &tronGetAccountResourceResponse{} + } + if rewardResp == nil { + rewardResp = &tronGetRewardResponse{} + } + + stakedEnergy := new(big.Int) + stakedBandwidth := new(big.Int) + for i := range accountResp.FrozenV2 { + frozen := &accountResp.FrozenV2[i] + if frozen.Amount == nil || *frozen.Amount <= 0 { + continue + } + amount := big.NewInt(*frozen.Amount) + // In Stake 2.0 only BANDWIDTH and ENERGY produce frozen TRX; TRON_POWER + // is a derived voting weight, not a separate stake, so any TRON_POWER + // entry is intentionally excluded from stakedBalance. + if frozen.Type == nil || *frozen.Type == tronResourceBandwidth { + stakedBandwidth.Add(stakedBandwidth, amount) + } else if *frozen.Type == tronResourceEnergy { + stakedEnergy.Add(stakedEnergy, amount) + } + } + + stakedBalance := new(big.Int).Add(stakedBandwidth, stakedEnergy) + + unstakingBatches := make([]bchain.TronUnstakingBatch, 0, len(accountResp.UnfrozenV2)) + for i := range accountResp.UnfrozenV2 { + unfreeze := &accountResp.UnfrozenV2[i] + if unfreeze.UnfreezeAmount == nil || *unfreeze.UnfreezeAmount <= 0 { + continue + } + expireTime := int64(0) + if unfreeze.UnfreezeExpireTime != nil && *unfreeze.UnfreezeExpireTime > 0 { + expireTime = *unfreeze.UnfreezeExpireTime / 1000 + } + unstakingBatches = append(unstakingBatches, bchain.TronUnstakingBatch{ + Amount: strconv.FormatInt(*unfreeze.UnfreezeAmount, 10), + ExpireTime: expireTime, + }) + } + + votes := make([]bchain.TronVote, 0, len(accountResp.Votes)) + for i := range accountResp.Votes { + vote := &accountResp.Votes[i] + address := ToTronAddressFromAddress(vote.VoteAddress) + if address == "" { + continue + } + if vote.VoteCount == nil || *vote.VoteCount <= 0 { + continue + } + votes = append(votes, bchain.TronVote{ + Address: address, + VoteCount: strconv.FormatInt(*vote.VoteCount, 10), + }) + } + + totalVotingPower := max(resourceResp.TronPowerLimit, 0) + availableVotingPower := max(totalVotingPower-resourceResp.TronPowerUsed, 0) + unclaimedReward := max(rewardResp.Reward, 0) + delegatedEnergy := max(accountResp.AccountResource.DelegatedFrozenV2BalanceForEnergy, 0) + delegatedBandwidth := max(accountResp.DelegatedFrozenV2BalanceForBandwidth, 0) + + return &bchain.TronStakingInfo{ + StakedBalance: stakedBalance.String(), + StakedBalanceEnergy: stakedEnergy.String(), + StakedBalanceBandwidth: stakedBandwidth.String(), + UnstakingBatches: unstakingBatches, + TotalVotingPower: strconv.FormatInt(totalVotingPower, 10), + AvailableVotingPower: strconv.FormatInt(availableVotingPower, 10), + Votes: votes, + UnclaimedReward: strconv.FormatInt(unclaimedReward, 10), + DelegatedBalanceEnergy: strconv.FormatInt(delegatedEnergy, 10), + DelegatedBalanceBandwidth: strconv.FormatInt(delegatedBandwidth, 10), + } +} + +func (b *TronRPC) SendRawTransaction(tx string, disableAlternativeRPC bool) (string, error) { + ctx, cancel := context.WithTimeout(b.requestContext(), 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) requestTransactionFromPending(ctx context.Context, txid string) (*tronGetTransactionByIDResponse, error) { + raw, err := requestRawMessage( + ctx, + b.fullNodeHTTP, + "/wallet/gettransactionfrompending", + 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) 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) requestAccount(ctx context.Context, address string) (*tronGetAccountResponse, error) { + req := map[string]any{ + "address": address, + "visible": true, + } + var resp tronGetAccountResponse + if err := b.fullNodeHTTP.Request(ctx, "/wallet/getaccount", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func (b *TronRPC) requestReward(ctx context.Context, address string) (*tronGetRewardResponse, error) { + req := map[string]any{ + "address": address, + "visible": true, + } + var resp tronGetRewardResponse + if err := b.fullNodeHTTP.Request(ctx, "/wallet/getReward", 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 limit <= 0 || 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..6c5f306119 --- /dev/null +++ b/bchain/coins/tron/tronparser.go @@ -0,0 +1,360 @@ +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 { + if len(addrDesc) == 0 { + return "" + } + + 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..0fc653ffdf --- /dev/null +++ b/bchain/coins/tron/tronrpc.go @@ -0,0 +1,1221 @@ +package tron + +import ( + "context" + "encoding/json" + "math/big" + "net" + "net/url" + "strings" + "sync" + "sync/atomic" + "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" + "github.com/trezor/blockbook/common" +) + +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"` + Data string `json:"data,omitempty"` + Contract []tronTxContract `json:"contract"` + } `json:"raw_data"` +} + +type TronRPC struct { + *eth.EthereumRPC + Parser *TronParser + ChainConfig *TronConfiguration + // mq is the ZeroMQ subscription; nil means ZeroMQ is disabled (polling-only mode). + mq *bchain.MQ + // callCtx is the base context for RPC calls (the embedded RPC client and the + // HTTP node clients); Shutdown cancels it so an in-flight sync call aborts + // promptly. The rpc-client side is also covered by CloseRPC; this additionally + // reaches the HTTP node fetches, which CloseRPC cannot. + callCtx context.Context + cancelCall context.CancelFunc + 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 + // lastNotifyNs is the UnixNano of the last ZeroMQ block notification that drove + // a tip refresh. tipWatchdog uses it to detect a silently stalled ZeroMQ feed + // (Tron has no newHeads WS subscription; if the publisher stops, nothing errors). + lastNotifyNs atomic.Int64 +} + +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") + } + + // ethChainConfig.RPCTimeout has already been clamped to a positive value by + // NewEthereumRPC, so the HTTP node clients inherit the same finite timeout. + timeout := time.Duration(ethChainConfig.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 + + tronRpc.callCtx, tronRpc.cancelCall = context.WithCancel(context.Background()) + + return tronRpc, nil +} + +// requestContext returns the base context for RPC calls. Shutdown cancels it so +// in-flight calls abort promptly. Falls back to context.Background() when unset +// (e.g. a directly-constructed test instance). +func (b *TronRPC) requestContext() context.Context { + if b.callCtx != nil { + return b.callCtx + } + return context.Background() +} + +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(b.requestContext(), 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 +} + +// setBestHeader stores h as the cached tip and reports whether it changed. +// +// Unlike EthereumRPC.setBestHeader this is intentionally NON-monotonic: a lower +// height is accepted. Tron's tip is never taken from the feed header (the ZeroMQ +// notification carries none) — it is always an HTTP re-query (refreshBestHeaderFromChain), +// refreshed on every notification and on a tronBestHeaderMaxAge timer, so the cache +// is meant to track whatever the backend currently reports. Accepting a lower height +// is what lets a genuine rollback surface immediately to resyncIndex, so Tron is not +// subject to the frozen-tip masking that the EVM monotonic guard introduces (and which +// EVM has to undo with a watchdog regress). +// +// Tradeoff: with a load-balanced Tron RPC, a single lagging node answering a re-query +// could regress the tip and trip a spurious fork in resyncIndex (the case the EVM +// monotonic guard exists to prevent). That is acceptable for the common single-node +// java-tron backend; if Tron is ever fronted by a load balancer, port the EVM pattern +// here (monotonic hot path + on-advance liveness + allowRegress watchdog poll). +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(b.requestContext(), 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) markNotifyAlive() { + b.lastNotifyNs.Store(time.Now().UnixNano()) +} + +func (b *TronRPC) newBlockNotifier() { + for range b.newBlockNotifyCh { + // Record that the ZeroMQ feed is delivering (the signal tipWatchdog watches); + // watchdog fallback polls deliberately do not touch this, so they cannot mask + // a dead feed. + b.markNotifyAlive() + 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) + } + } +} + +// tipWatchdog detects a silently stalled ZeroMQ block feed. Unlike the EVM +// watchdog there is no WS subscription to reconnect (Tron's ZeroMQ SUB +// auto-reconnects at the transport level), so on a stall it polls the tip +// directly and, if it advanced, re-triggers sync. This keeps the index moving +// when the publisher goes quiet instead of waiting for the ~15-minute periodic +// resync tick. Started exactly once via newBlockNotifyOnce. +func (b *TronRPC) tipWatchdog() { + threshold := b.TipStaleThreshold() + var interval time.Duration + if b.mq == nil { + // Polling-only mode: sample at the chain's block cadence, subject to the + // shared 5-60s clamp below (so sub-5s chains poll at the 5s floor). + interval = time.Duration(b.ChainConfig.AverageBlockTimeMs) * time.Millisecond + } else { + interval = threshold / 3 + } + if interval < 5*time.Second { + interval = 5 * time.Second + } + if interval > 60*time.Second { + interval = 60 * time.Second + } + glog.Infof("TronRPC: tip watchdog started, stall threshold %s, sampling every %s", threshold, interval) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + if common.IsInShutdown() { + return + } + b.tipWatchdogTick(threshold) + } +} + +// tipWatchdogTick is one watchdog evaluation, split out from the ticker loop so +// it is unit-testable with an injected threshold and a fake client (no wait). +func (b *TronRPC) tipWatchdogTick(threshold time.Duration) { + // Heartbeat: prove the watchdog goroutine is still ticking (see eth tipWatchdogTick). + // rate(blockbook_backend_subscription_events{event="watchdog_tick"})==0 means it died. + b.ObserveSubscriptionEvent("zeromq", "watchdog_tick") + + // Polling-only mode (ZeroMQ disabled): always poll the tip directly. + // lastNotifyNs is never armed in this mode so the staleness gate below would never fire; + // bypass it entirely and drive sync from the ticker. + if b.mq == nil { + updated, err := b.refreshBestHeaderFromChain() + if err != nil { + glog.Error("TronRPC: tip watchdog tip poll error ", err) + return + } + if updated && b.PushHandler != nil { + b.ObserveSubscriptionEvent("zeromq", "watchdog_tip_advanced") + b.PushHandler(bchain.NotificationNewBlock) + b.PushHandler(bchain.NotificationNewTx) + } + return + } + + lastNs := b.lastNotifyNs.Load() + if lastNs == 0 { + return + } + age := time.Since(time.Unix(0, lastNs)) + b.SetSubscriptionAgeSeconds(age.Seconds()) + if age < threshold { + return + } + glog.Warningf("TronRPC: ZeroMQ block feed silent for %s (threshold %s); polling tip", age.Truncate(time.Second), threshold) + b.ObserveSubscriptionEvent("zeromq", "watchdog_stall") + updated, err := b.refreshBestHeaderFromChain() + if err != nil { + glog.Error("TronRPC: tip watchdog tip poll error ", err) + return + } + if updated && b.PushHandler != nil { + b.ObserveSubscriptionEvent("zeromq", "watchdog_tip_advanced") + b.PushHandler(bchain.NotificationNewBlock) + b.PushHandler(bchain.NotificationNewTx) + } + // Deliberately do NOT re-arm liveness here: lastNotifyNs is refreshed only by a + // real ZeroMQ delivery (newBlockNotifier), never by the watchdog's own poll — + // the same invariant the EVM watchdog keeps. If the poll re-armed it, a feed that + // has gone permanently silent while the poll keeps advancing the tip would look + // alive and subscription_age_seconds would sawtooth below the threshold instead + // of climbing past it, hiding the dead feed from any age-based alert. Polling + // every sample interval while the feed stays silent is the intended recovery, not + // a problem: Tron blocks are seconds apart, so reaching the threshold already + // means an abnormal gap, and the poll keeps sync moving until delivery resumes. +} + +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 + + if b.ChainConfig.MessageQueueBinding == "" { + glog.Warning("ZeroMQ subscription disabled: message_queue_binding is empty; relying on polling") + } else { + 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 + } + + // Arm the watchdog's staleness clock once the ZeroMQ feed is established, not on + // the first notification that advances the tip. Otherwise a feed that never + // advances would leave lastNotifyNs at 0 and the watchdog's (lastNs == 0) gate + // disabled (see EthereumRPC.subscribeEvents for the same fix on the EVM path). + b.markNotifyAlive() + } + + // Start the watchdog/notifier goroutines last, after b.mq is set: the watchdog + // reads b.mq to choose polling-only vs ZeroMQ mode, and launching it after the + // (write-once) assignment makes that value visible without synchronization. + b.newBlockNotifyOnce.Do(func() { + go b.newBlockNotifier() + go b.tipWatchdog() + }) + + return nil +} + +func (b *TronRPC) Shutdown(ctx context.Context) error { + // Abort in-flight RPC-client calls (GetBlockHash, raw block fetch, tip re-query) + // so a sync call cannot block shutdown up to the RPC timeout. CloseRPC mirrors + // EthereumRPC.Shutdown; cancelCall additionally aborts the HTTP node fetches + // (tx details) that CloseRPC cannot reach. + b.EthereumRPC.CloseRPC() + if b.cancelCall != nil { + b.cancelCall() + } + 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(b.requestContext(), 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 { + if isTronTxNotFound(err) { + return b.GetTransactionForMempool(txid) + } + 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 +} + +// GetTransactionForMempool returns a transaction by the transaction ID using +// the full node HTTP API +func (b *TronRPC) GetTransactionForMempool(txid string) (*bchain.Tx, error) { + ctx, cancel := context.WithTimeout(b.requestContext(), b.Timeout) + defer cancel() + + txByID, err := b.requestTransactionFromPending(ctx, txid) + if err != nil { + if isTronTxNotFound(err) { + if b.Mempool != nil { + b.Mempool.RemoveTransactionFromMempool(strip0xPrefix(txid)) + } + return nil, bchain.ErrTxNotFound + } + return nil, err + } + + txInfo := &tronGetTransactionInfoByIDResponse{ID: strip0xPrefix(txid)} + blockTime, blockNumber, hasBlockNumber := tronTxMeta(txInfo) + confirmations := b.computeConfirmationsFromBlockNumber(txid, blockNumber, hasBlockNumber) + return b.buildTxFromHTTPData(txByID, txInfo, blockTime, confirmations, nil, false) +} + +// 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(b.requestContext(), 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(b.requestContext(), 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) +} + +// EthereumTypeGetNonces returns the account nonce. Tron exposes only the latest +// (confirmed) nonce via NonceAt in a single call, so the pending and confirmed +// values are identical and the withConfirmed flag carries no extra cost here. +func (b *TronRPC) EthereumTypeGetNonces(addrDesc bchain.AddressDescriptor, withConfirmed bool) (uint64, uint64, bool, error) { + ctx, cancel := context.WithTimeout(b.requestContext(), b.Timeout) + defer cancel() + n, err := b.Client.NonceAt(ctx, addrDesc, nil) + if err != nil { + return 0, 0, false, err + } + // the single NonceAt call already yields the latest nonce, so confirmed is + // available whenever it was requested + return n, n, withConfirmed, 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..a45697822d --- /dev/null +++ b/bchain/coins/tron/tronrpc_test.go @@ -0,0 +1,968 @@ +//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 requireMockLastPath(t *testing.T, mock *MockTronHTTPClient, wantPath string) { + t.Helper() + gotPath, _ := mock.SnapshotLastRequest() + require.Equal(t, wantPath, gotPath) +} + +func requireMockLastRequest(t *testing.T, mock *MockTronHTTPClient, wantPath string, wantBody interface{}) { + t.Helper() + gotPath, gotBody := mock.SnapshotLastRequest() + require.Equal(t, wantPath, gotPath) + require.Equal(t, wantBody, gotBody) +} + +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) GetTxAssets(assetGuid uint64) bchain.MempoolTxidEntries { + return nil +} + +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) + requireMockLastRequest(t, solidityHTTP, "/walletsolidity/gettransactionbyid", map[string]string{"value": "abc"}) + requireMockLastRequest(t, fullNodeHTTP, "/wallet/gettransactionbyid", map[string]string{"value": "abc"}) +} + +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) + requireMockLastPath(t, solidityHTTP, "/walletsolidity/gettransactionbyid") + requireMockLastPath(t, fullNodeHTTP, "/wallet/gettransactionbyid") +} + +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) + requireMockLastPath(t, solidityHTTP, "/walletsolidity/gettransactioninfobyid") + requireMockLastPath(t, fullNodeHTTP, "/wallet/gettransactioninfobyid") +} + +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) + requireMockLastPath(t, solidityHTTP, "/walletsolidity/gettransactionbyid") + requireMockLastPath(t, fullNodeHTTP, "") + + 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) + requireMockLastPath(t, solidityHTTP, "/walletsolidity/gettransactioninfobyid") + requireMockLastPath(t, fullNodeHTTP, "/wallet/gettransactionbyid") + + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + require.True(t, ok) + require.Nil(t, csd.Receipt) +} + +func TestTronRPC_GetTransactionForMempool_UsesPendingPoolHTTP(t *testing.T) { + txByID := tronGetTransactionByIDResponse{ + TxID: "abc", + } + txByID.RawData.Contract = []tronTxContract{{ + Type: "TransferContract", + }} + txByID.RawData.Contract[0].Parameter.Value.OwnerAddress = "410000000000000000000000000000000000000001" + txByID.RawData.Contract[0].Parameter.Value.ToAddress = "410000000000000000000000000000000000000002" + txByID.RawData.Contract[0].Parameter.Value.Amount = int64Ptr(123) + + solidityHTTP := &MockTronHTTPClient{ + Resp: map[string]any{}, + } + fullNodeHTTP := &MockTronHTTPClient{ + RespByPath: map[string]interface{}{ + "/wallet/gettransactionfrompending": txByID, + }, + } + + tronRPC := &TronRPC{ + EthereumRPC: ð.EthereumRPC{ + Timeout: time.Second, + }, + Parser: NewTronParser(1, false), + fullNodeHTTP: fullNodeHTTP, + solidityNodeHTTP: solidityHTTP, + } + + tx, err := tronRPC.GetTransactionForMempool("0xabc") + require.NoError(t, err) + require.NotNil(t, tx) + require.Equal(t, "abc", tx.Txid) + require.Equal(t, int64(123), tx.Vout[0].ValueSat.Int64()) + requireMockLastPath(t, solidityHTTP, "") + + paths, _ := fullNodeHTTP.SnapshotRequests() + require.Equal(t, []string{ + "/wallet/gettransactionfrompending", + }, paths) + + csd, ok := tx.CoinSpecificData.(bchain.EthereumSpecificData) + require.True(t, ok) + require.Nil(t, csd.Receipt) +} + +func TestTronRPC_GetTransaction_FallsBackToPendingPool(t *testing.T) { + txByID := tronGetTransactionByIDResponse{ + TxID: "abc", + } + txByID.RawData.Contract = []tronTxContract{{ + Type: "TransferContract", + }} + txByID.RawData.Contract[0].Parameter.Value.OwnerAddress = "410000000000000000000000000000000000000001" + txByID.RawData.Contract[0].Parameter.Value.ToAddress = "410000000000000000000000000000000000000002" + txByID.RawData.Contract[0].Parameter.Value.Amount = int64Ptr(123) + + solidityHTTP := &MockTronHTTPClient{ + Resp: map[string]any{}, + } + fullNodeHTTP := &MockTronHTTPClient{ + RespByPath: map[string]interface{}{ + "/wallet/gettransactioninfobyid": map[string]any{}, + "/wallet/gettransactionfrompending": txByID, + }, + } + + 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, uint32(0), tx.Confirmations) + require.Equal(t, int64(123), tx.Vout[0].ValueSat.Int64()) + + requireMockLastPath(t, solidityHTTP, "/walletsolidity/gettransactioninfobyid") + paths, _ := fullNodeHTTP.SnapshotRequests() + require.Equal(t, []string{ + "/wallet/gettransactioninfobyid", + "/wallet/gettransactionfrompending", + }, paths) + + 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) + requireMockLastRequest(t, mockHTTP, "/walletsolidity/gettransactionbyid", map[string]string{"value": "788b4d0ca432b3d07f895dffe80429bf58398d0e86222460b07f9db38e238803"}) +} + +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) + requireMockLastRequest(t, mockHTTP, "/walletsolidity/gettransactioninfobyid", map[string]string{"value": "788b4d0ca432b3d07f895dffe80429bf58398d0e86222460b07f9db38e238803"}) +} + +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) + requireMockLastPath(t, mockHTTP, "/walletsolidity/gettransactioninfobyid") +} + +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) + requireMockLastRequest(t, mockHTTP, "/wallet/broadcasthex", map[string]string{"transaction": "deadbeef"}) +} + +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) + requireMockLastRequest(t, mockHTTP, "/wallet/gettransactionlistfrompending", map[string]any{}) +} + +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{ + RespByPath: map[string]interface{}{ + "/wallet/getaccountresource": tronGetAccountResourceResponse{ + FreeNetLimit: 600, + FreeNetUsed: 100, + NetLimit: 400, + NetUsed: 250, + TotalNetLimit: 43200000000, + TotalNetWeight: 68292467803, + EnergyLimit: 9000, + EnergyUsed: 1234, + TotalEnergyLimit: 180000000000, + TotalEnergyWeight: 2363311832, + TronPowerUsed: 3, + TronPowerLimit: 10, + }, + "/wallet/getaccount": map[string]any{ + "address": "TLUqyV9rGYXZ2E8kXe6J3P1rvYV1Au1Goe", + "frozenV2": []map[string]any{ + {"amount": int64(2000000)}, + {"type": "ENERGY", "amount": int64(5000000)}, + {"type": "TRON_POWER"}, + }, + "unfrozenV2": []map[string]any{ + { + "unfreeze_amount": int64(1112757), + "unfreeze_expire_time": int64(1777018452000), + }, + }, + "votes": []map[string]any{ + { + "vote_address": "TJvaAeFb8Lykt9RQcVyyTFN2iDvGMuyD4M", + "vote_count": int64(20), + }, + }, + "account_resource": map[string]any{ + "delegated_frozenV2_balance_for_energy": int64(3210000), + }, + "delegated_frozenV2_balance_for_bandwidth": int64(654000), + }, + "/wallet/getReward": map[string]any{ + "reward": int64(42767), + }, + }, + } + 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, + "totalEnergyLimit":180000000000, + "totalEnergyWeight":2363311832, + "totalBandwidthLimit":43200000000, + "totalBandwidthWeight":68292467803, + "stakingInfo":{ + "stakedBalance":"7000000", + "stakedBalanceEnergy":"5000000", + "stakedBalanceBandwidth":"2000000", + "unstakingBatches":[{"amount":"1112757","expireTime":1777018452}], + "totalVotingPower":"10", + "availableVotingPower":"7", + "votes":[{"address":"TJvaAeFb8Lykt9RQcVyyTFN2iDvGMuyD4M","voteCount":"20"}], + "unclaimedReward":"42767", + "delegatedBalanceEnergy":"3210000", + "delegatedBalanceBandwidth":"654000" + } + }`, string(payload)) + paths, bodies := mockHTTP.SnapshotRequests() + require.ElementsMatch(t, []string{ + "/wallet/getaccountresource", + "/wallet/getaccount", + "/wallet/getReward", + }, paths) + for _, reqBody := range bodies { + require.Equal(t, map[string]any{ + "address": "TLUqyV9rGYXZ2E8kXe6J3P1rvYV1Au1Goe", + "visible": true, + }, reqBody) + } +} + +func TestTronRPC_GetAddressChainExtraData_MissingFieldsClampToZero(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + RespByPath: map[string]interface{}{ + "/wallet/getaccountresource": map[string]any{ + "freeNetLimit": int64(100), + "freeNetUsed": int64(150), + "NetLimit": int64(50), + "NetUsed": int64(10), + "EnergyUsed": int64(20), + "tronPowerUsed": int64(7), + "tronPowerLimit": int64(5), + }, + "/wallet/getaccount": map[string]any{ + "address": "41734c2f23ab41c52308d1206c4eb5fe8e124e6898", + "frozenV2": []map[string]any{ + {"amount": int64(-10)}, + {"type": "ENERGY", "amount": int64(2000000)}, + }, + "unfrozenV2": []map[string]any{ + { + "unfreeze_amount": int64(1000000), + "unfreeze_expire_time": int64(1700000001000), + }, + {}, + }, + "votes": []map[string]any{ + { + "vote_address": "TJvaAeFb8Lykt9RQcVyyTFN2iDvGMuyD4M", + "vote_count": int64(15), + }, + { + "vote_count": int64(7), + }, + }, + "account_resource": map[string]any{ + "delegated_frozenV2_balance_for_energy": int64(-1), + }, + }, + "/wallet/getReward": map[string]any{}, + }, + } + + 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, + StakingInfo: &bchain.TronStakingInfo{ + StakedBalance: "2000000", + StakedBalanceEnergy: "2000000", + StakedBalanceBandwidth: "0", + UnstakingBatches: []bchain.TronUnstakingBatch{ + { + Amount: "1000000", + ExpireTime: 1700000001, + }, + }, + TotalVotingPower: "5", + AvailableVotingPower: "0", + Votes: []bchain.TronVote{ + { + Address: "TJvaAeFb8Lykt9RQcVyyTFN2iDvGMuyD4M", + VoteCount: "15", + }, + }, + UnclaimedReward: "0", + DelegatedBalanceEnergy: "0", + DelegatedBalanceBandwidth: "0", + }, + }, extra) +} + +func TestTronRPC_GetAddressChainExtraData_SkipsVotesWithoutPositiveCount(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + RespByPath: map[string]interface{}{ + "/wallet/getaccountresource": tronGetAccountResourceResponse{ + TronPowerLimit: 9, + TronPowerUsed: 2, + }, + "/wallet/getaccount": map[string]any{ + "address": "TLUqyV9rGYXZ2E8kXe6J3P1rvYV1Au1Goe", + "votes": []map[string]any{ + { + "vote_address": "TJvaAeFb8Lykt9RQcVyyTFN2iDvGMuyD4M", + }, + { + "vote_address": "TEoMgjvT6Z5MZ7BfY8M8Pt6APxMfkXxM9P", + "vote_count": int64(0), + }, + { + "vote_address": "TS7Rr3V7wYj8D45Rta7kcxkW5n4M57cC8S", + "vote_count": int64(-3), + }, + { + "vote_address": "TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH", + "vote_count": int64(7), + }, + }, + }, + "/wallet/getReward": map[string]any{}, + }, + } + + 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) + + var extra bchain.TronAccountExtraData + require.NoError(t, json.Unmarshal(payload, &extra)) + require.NotNil(t, extra.StakingInfo) + require.Equal(t, []bchain.TronVote{ + { + Address: "TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH", + VoteCount: "7", + }, + }, extra.StakingInfo.Votes) +} + +func TestTronRPC_GetAddressChainExtraData_NonExistentAccount_OmitsStakingInfo(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + RespByPath: map[string]interface{}{ + "/wallet/getaccountresource": tronGetAccountResourceResponse{ + FreeNetLimit: 600, + FreeNetUsed: 100, + NetLimit: 400, + NetUsed: 250, + EnergyLimit: 9000, + EnergyUsed: 1234, + }, + // Non-existent account: Tron node returns {} (no address field). + "/wallet/getaccount": map[string]any{}, + "/wallet/getReward": map[string]any{}, + }, + } + 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) + + var extra bchain.TronAccountExtraData + require.NoError(t, json.Unmarshal(payload, &extra)) + require.Equal(t, bchain.TronAccountExtraData{ + AvailableStakedBandwidth: 150, + TotalStakedBandwidth: 400, + AvailableFreeBandwidth: 500, + TotalFreeBandwidth: 600, + AvailableEnergy: 7766, + TotalEnergy: 9000, + StakingInfo: nil, + }, extra) +} + +func TestTronRPC_GetAddressChainExtraData_GetAccountFailure_OmitsStakingInfo(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + RespByPath: map[string]interface{}{ + "/wallet/getaccountresource": tronGetAccountResourceResponse{ + FreeNetLimit: 600, + FreeNetUsed: 100, + NetLimit: 400, + NetUsed: 250, + EnergyLimit: 9000, + EnergyUsed: 1234, + }, + "/wallet/getReward": map[string]any{}, + }, + ErrByPath: map[string]error{ + "/wallet/getaccount": errors.New("backend /wallet/getaccount temporary failure"), + }, + } + 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) + + var extra bchain.TronAccountExtraData + require.NoError(t, json.Unmarshal(payload, &extra)) + require.Equal(t, bchain.TronAccountExtraData{ + AvailableStakedBandwidth: 150, + TotalStakedBandwidth: 400, + AvailableFreeBandwidth: 500, + TotalFreeBandwidth: 600, + AvailableEnergy: 7766, + TotalEnergy: 9000, + StakingInfo: nil, + }, extra) +} + +func TestTronRPC_GetAddressChainExtraData_GetRewardFailure_UsesZeroReward(t *testing.T) { + mockHTTP := &MockTronHTTPClient{ + RespByPath: map[string]interface{}{ + "/wallet/getaccountresource": tronGetAccountResourceResponse{ + NetLimit: 100, + NetUsed: 10, + TronPowerLimit: 3, + }, + "/wallet/getaccount": map[string]any{ + "address": "TLUqyV9rGYXZ2E8kXe6J3P1rvYV1Au1Goe", + "frozenV2": []map[string]any{ + {"amount": int64(3000000)}, + }, + }, + }, + ErrByPath: map[string]error{ + "/wallet/getReward": errors.New("backend /wallet/getReward temporary failure"), + }, + } + 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) + + var extra bchain.TronAccountExtraData + require.NoError(t, json.Unmarshal(payload, &extra)) + require.NotNil(t, extra.StakingInfo) + require.Equal(t, "0", extra.StakingInfo.UnclaimedReward) + paths, _ := mockHTTP.SnapshotRequests() + require.ElementsMatch(t, []string{ + "/wallet/getaccountresource", + "/wallet/getaccount", + "/wallet/getReward", + }, paths) +} + +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) + requireMockLastRequest(t, mockHTTP, "/walletsolidity/getblock", map[string]any{"detail": false}) +} + +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/tronrpc_tip_watchdog_test.go b/bchain/coins/tron/tronrpc_tip_watchdog_test.go new file mode 100644 index 0000000000..bf5c535a2c --- /dev/null +++ b/bchain/coins/tron/tronrpc_tip_watchdog_test.go @@ -0,0 +1,148 @@ +//go:build unittest + +package tron + +import ( + "context" + "errors" + "math/big" + "testing" + "time" + + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" +) + +// Tron reuses EthereumRPC.TipStaleThreshold so its ZeroMQ-feed watchdog sizes its +// stall window from the same block-cadence policy as the EVM watchdog. +func TestTronTipStaleThreshold(t *testing.T) { + tests := []struct { + name string + averageBlockTime int + want time.Duration + }{ + {name: "tron 3s -> 30 blocks", averageBlockTime: 3000, want: 90 * time.Second}, + {name: "unset falls back to max", averageBlockTime: 0, want: 5 * time.Minute}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &TronRPC{EthereumRPC: ð.EthereumRPC{ChainConfig: ð.Configuration{AverageBlockTimeMs: tt.averageBlockTime}}} + if got := b.TipStaleThreshold(); got != tt.want { + t.Fatalf("TipStaleThreshold() = %s, want %s", got, tt.want) + } + }) + } +} + +// --- minimal fakes implementing only what the watchdog touches --- + +type stubHeader struct{ n int64 } + +func (h stubHeader) Hash() string { return string(rune(h.n)) } +func (h stubHeader) Number() *big.Int { return big.NewInt(h.n) } +func (h stubHeader) Difficulty() *big.Int { return big.NewInt(0) } + +type stubHeaderClient struct { + bchain.EVMClient // embed for the methods the watchdog never calls + height int64 +} + +func (c *stubHeaderClient) HeaderByNumber(context.Context, *big.Int) (bchain.EVMHeader, error) { + return stubHeader{n: c.height}, nil +} + +// stubTronHTTP makes the solidified-head lookup fail; refreshBestHeaderFromChain +// logs and ignores it, so the tip refresh still succeeds. +type stubTronHTTP struct{} + +func (stubTronHTTP) Request(context.Context, string, interface{}, interface{}) error { + return errors.New("no solidified head in test") +} + +// Tron has no WS to reconnect; on a stalled ZeroMQ feed the watchdog must poll the +// tip and re-trigger sync (new block + mempool refresh) without waiting on the ticker. +func TestTronTipWatchdogTickOnStaleFeed(t *testing.T) { + pushes := make(chan bchain.NotificationType, 4) + ethRPC := ð.EthereumRPC{ChainConfig: ð.Configuration{AverageBlockTimeMs: 3000}, Timeout: time.Second} + ethRPC.Client = &stubHeaderClient{height: 200} + ethRPC.PushHandler = func(nt bchain.NotificationType) { pushes <- nt } + b := &TronRPC{EthereumRPC: ethRPC, solidityNodeHTTP: stubTronHTTP{}} + b.lastNotifyNs.Store(time.Now().Add(-time.Hour).UnixNano()) + + b.tipWatchdogTick(time.Millisecond) + + for _, want := range []bchain.NotificationType{bchain.NotificationNewBlock, bchain.NotificationNewTx} { + select { + case nt := <-pushes: + if nt != want { + t.Fatalf("pushed %v, want %v", nt, want) + } + default: + t.Fatalf("watchdog did not push %v on a stale feed", want) + } + } +} + +// The watchdog's own tip poll must not refresh feed liveness: lastNotifyNs is +// stamped only by a real ZeroMQ delivery (newBlockNotifier). If the poll re-armed +// it, a feed that has gone permanently silent while the poll keeps advancing the +// tip would keep looking alive and subscription_age_seconds would sawtooth below +// the threshold instead of climbing past it, hiding the dead feed from alerts. +func TestTronTipWatchdogPollDoesNotRefreshLiveness(t *testing.T) { + ethRPC := ð.EthereumRPC{ChainConfig: ð.Configuration{AverageBlockTimeMs: 3000}, Timeout: time.Second} + ethRPC.Client = &stubHeaderClient{height: 200} + ethRPC.PushHandler = func(bchain.NotificationType) {} + b := &TronRPC{EthereumRPC: ethRPC, solidityNodeHTTP: stubTronHTTP{}} + stale := time.Now().Add(-time.Hour).UnixNano() + b.lastNotifyNs.Store(stale) + + b.tipWatchdogTick(time.Millisecond) + + if got := b.lastNotifyNs.Load(); got != stale { + t.Fatalf("watchdog poll refreshed liveness (lastNotifyNs %d -> %d); a permanently dead feed would be masked", stale, got) + } +} + +// TestTronTipWatchdogTickPollingOnlyDoesNotArmLiveness verifies that the +// polling-only path does not set lastNotifyNs. That field is reserved for real +// ZeroMQ deliveries; if the poll set it, a permanently dead ZeroMQ feed would +// look alive and subscription_age_seconds would never climb past the threshold. +func TestTronTipWatchdogTickPollingOnlyDoesNotArmLiveness(t *testing.T) { + ethRPC := ð.EthereumRPC{ChainConfig: ð.Configuration{AverageBlockTimeMs: 3000}, Timeout: time.Second} + ethRPC.Client = &stubHeaderClient{height: 200} + ethRPC.PushHandler = func(bchain.NotificationType) {} + b := &TronRPC{EthereumRPC: ethRPC, solidityNodeHTTP: stubTronHTTP{}} + // lastNotifyNs starts at 0 (never armed in polling-only mode). + + b.tipWatchdogTick(time.Hour) + + if got := b.lastNotifyNs.Load(); got != 0 { + t.Fatalf("polling-only mode armed liveness (lastNotifyNs = %d); a dead ZeroMQ feed would be masked", got) + } +} + +// TestTronTipWatchdogTickPollingOnlyImmediate verifies that when ZeroMQ is +// disabled (b.mq == nil), tipWatchdogTick polls the tip immediately without +// requiring lastNotifyNs to be armed and without waiting for the stale threshold. +// In ZeroMQ mode a lastNs==0 causes an early return; polling-only mode must +// bypass that gate entirely. +func TestTronTipWatchdogTickPollingOnlyImmediate(t *testing.T) { + pushes := make(chan bchain.NotificationType, 4) + ethRPC := ð.EthereumRPC{ChainConfig: ð.Configuration{AverageBlockTimeMs: 3000}, Timeout: time.Second} + ethRPC.Client = &stubHeaderClient{height: 200} + ethRPC.PushHandler = func(nt bchain.NotificationType) { pushes <- nt } + b := &TronRPC{EthereumRPC: ethRPC, solidityNodeHTTP: stubTronHTTP{}} + + b.tipWatchdogTick(time.Hour) + + for _, want := range []bchain.NotificationType{bchain.NotificationNewBlock, bchain.NotificationNewTx} { + select { + case nt := <-pushes: + if nt != want { + t.Fatalf("pushed %v, want %v", nt, want) + } + default: + t.Fatalf("polling-only mode did not push %v; tipWatchdogTick returned early on lastNs==0 gate", want) + } + } +} diff --git a/bchain/coins/tron/txextra.go b/bchain/coins/tron/txextra.go new file mode 100644 index 0000000000..57200568ae --- /dev/null +++ b/bchain/coins/tron/txextra.go @@ -0,0 +1,297 @@ +package tron + +import ( + "encoding/hex" + "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] +} + +const tronNoteMaxBytes = 4096 + +func tronDecodeNote(noteHex string) string { + noteHex = strip0xPrefix(strings.TrimSpace(noteHex)) + if noteHex == "" { + return "" + } + b, err := hex.DecodeString(noteHex) + if err != nil { + return "" + } + if len(b) > tronNoteMaxBytes { + b = b[:tronNoteMaxBytes] + } + return strings.ToValidUTF8(string(b), "") +} + +func tronBuildExtraData(txByID *tronGetTransactionByIDResponse, txInfo *tronGetTransactionInfoByIDResponse) bchain.TronChainExtraData { + extra := bchain.TronChainExtraData{} + extra.FeeLimit = tronInt64PtrToString(txByID.RawData.FeeLimit) + extra.Note = tronDecodeNote(txByID.RawData.Data) + + 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{ + GasUsed: "0x0", + } + 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..f7ed4de45a --- /dev/null +++ b/bchain/coins/tron/txextra_test.go @@ -0,0 +1,514 @@ +//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_Note(t *testing.T) { + tests := []struct { + name string + data string + want string + }{ + { + name: "plain hex memo", + data: "74657374", + want: "test", + }, + { + name: "prefixed hex memo", + data: "0x48656c6c6f2054524f4e", + want: "Hello TRON", + }, + { + name: "empty memo", + data: "", + want: "", + }, + { + name: "invalid hex memo", + data: "not-hex", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + txByID := &tronGetTransactionByIDResponse{} + txByID.RawData.Data = tt.data + txInfo := &tronGetTransactionInfoByIDResponse{} + + extra := tronBuildExtraData(txByID, txInfo) + require.Equal(t, tt.want, extra.Note) + }) + } +} + +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) + require.Equal(t, "0x0", receipt.GasUsed) + + txInfo.Result = "FAILED" + receipt = tronBuildRpcReceipt(txInfo) + require.NotNil(t, receipt) + require.Equal(t, "0x0", receipt.Status) + require.Equal(t, "0x0", receipt.GasUsed) +} + +func TestTronBuildRpcReceipt_UsesEnergyUsageTotalAsGasUsed(t *testing.T) { + txInfo := &tronGetTransactionInfoByIDResponse{ + ID: "tx1", + } + txInfo.Receipt.EnergyUsageTotal = int64Ptr(14650) + + receipt := tronBuildRpcReceipt(txInfo) + require.NotNil(t, receipt) + require.Equal(t, "0x393a", receipt.GasUsed) +} + +func TestTronBuildRpcReceipt_NormalizesOmittedLogDataForTxCache(t *testing.T) { + contract := tronTxContract{Type: "TriggerSmartContract"} + contract.Parameter.Value.OwnerAddress = "41b47e4a2a3b6652af6c8e4396fc5e490b3e8fa827" + contract.Parameter.Value.ContractAddress = "TTg3AAJBYsDNjx5Moc5EPNsgJSa4anJQ3M" + + txByID := &tronGetTransactionByIDResponse{ + TxID: "a6bf472d7fbefa1a87f63b0626a98840f37a6863f4cdadc4a4aacfceff5c1073", + } + txByID.RawData.Contract = []tronTxContract{contract} + + txInfo := &tronGetTransactionInfoByIDResponse{ + BlockNumber: int64Ptr(1), + Log: []*bchain.RpcLog{ + { + Address: "TTg3AAJBYsDNjx5Moc5EPNsgJSa4anJQ3M", + Topics: []string{ + "6917c54e363c87122ded2db643033caa7634085108272a134387eb8e5ddee762", + "588c52d2eba6df506d44177ddda5e60b60842d3959ecf664d2c7b756b45f4820", + }, + }, + }, + } + + csd := tronBuildEthereumSpecificData(txByID, txInfo) + require.NotNil(t, csd.Receipt) + require.Equal(t, "0x", csd.Receipt.Logs[0].Data) + + parser := NewTronParser(1, false) + tx, err := parser.EthTxToTx(csd.Tx, csd.Receipt, nil, 0, 1, true) + require.NoError(t, err) + + _, err = parser.PackTx(tx, 1, 0) + require.NoError(t, err) +} + +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 941e731a54..500c25f74a 100644 --- a/bchain/coins/unobtanium/unobtaniumparser.go +++ b/bchain/coins/unobtanium/unobtaniumparser.go @@ -5,9 +5,9 @@ import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) // magic numbers @@ -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/unobtanium/unobtaniumparser_test.go b/bchain/coins/unobtanium/unobtaniumparser_test.go index 44cc03e243..52ef07d79c 100644 --- a/bchain/coins/unobtanium/unobtaniumparser_test.go +++ b/bchain/coins/unobtanium/unobtaniumparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { diff --git a/bchain/coins/unobtanium/unobtaniumrpc.go b/bchain/coins/unobtanium/unobtaniumrpc.go index 62d965584e..918dd3c8c8 100644 --- a/bchain/coins/unobtanium/unobtaniumrpc.go +++ b/bchain/coins/unobtanium/unobtaniumrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // UnobtaniumRPC is an interface to JSON-RPC bitcoind service diff --git a/bchain/coins/vertcoin/vertcoinparser.go b/bchain/coins/vertcoin/vertcoinparser.go index dde76fe652..6bde1b56b7 100644 --- a/bchain/coins/vertcoin/vertcoinparser.go +++ b/bchain/coins/vertcoin/vertcoinparser.go @@ -3,7 +3,7 @@ package vertcoin import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/btc" ) // magic numbers @@ -35,12 +35,14 @@ func init() { // VertcoinParser handle type VertcoinParser struct { - *btc.BitcoinLikeParser + *btc.BitcoinParser } // NewVertcoinParser returns new VertcoinParser instance func NewVertcoinParser(params *chaincfg.Params, c *btc.Configuration) *VertcoinParser { - return &VertcoinParser{BitcoinLikeParser: btc.NewBitcoinLikeParser(params, c)} + p := &VertcoinParser{BitcoinParser: btc.NewBitcoinParser(params, c)} + p.VSizeSupport = true + return p } // GetChainParams contains network parameters for the main Vertcoin network, diff --git a/bchain/coins/vertcoin/vertcoinparser_test.go b/bchain/coins/vertcoin/vertcoinparser_test.go index 192a61ae1b..ce1cbd672f 100644 --- a/bchain/coins/vertcoin/vertcoinparser_test.go +++ b/bchain/coins/vertcoin/vertcoinparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -90,6 +90,7 @@ func init() { Blocktime: 1529925180, Txid: "d58c11aa970449c3e0ee5e0cdf78532435a9d2b28a2da284a8dd4dd6bdd0331c", LockTime: 952180, + VSize: 223, Version: 1, Vin: []bchain.Vin{ { diff --git a/bchain/coins/vertcoin/vertcoinrpc.go b/bchain/coins/vertcoin/vertcoinrpc.go index 5ad113c1e2..9cf60bcb87 100644 --- a/bchain/coins/vertcoin/vertcoinrpc.go +++ b/bchain/coins/vertcoin/vertcoinrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // VertcoinRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/viacoin/viacoinparser.go b/bchain/coins/viacoin/viacoinparser.go index edd9fdcc8a..071cb09e53 100644 --- a/bchain/coins/viacoin/viacoinparser.go +++ b/bchain/coins/viacoin/viacoinparser.go @@ -5,9 +5,9 @@ import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) // magic numbers @@ -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/viacoin/viacoinparser_test.go b/bchain/coins/viacoin/viacoinparser_test.go index 669a18fe7a..d6f5b1e4b0 100644 --- a/bchain/coins/viacoin/viacoinparser_test.go +++ b/bchain/coins/viacoin/viacoinparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { diff --git a/bchain/coins/viacoin/viacoinrpc.go b/bchain/coins/viacoin/viacoinrpc.go index 6a33693236..bcb40dbd7e 100644 --- a/bchain/coins/viacoin/viacoinrpc.go +++ b/bchain/coins/viacoin/viacoinrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // ViacoinRPC is an interface to JSON-RPC bitcoind service diff --git a/bchain/coins/vipstarcoin/vipstarcoinparser.go b/bchain/coins/vipstarcoin/vipstarcoinparser.go index 3abc7b363f..8751ed85bd 100644 --- a/bchain/coins/vipstarcoin/vipstarcoinparser.go +++ b/bchain/coins/vipstarcoin/vipstarcoinparser.go @@ -7,9 +7,9 @@ import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/utils" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/utils" ) // magic numbers @@ -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 f9943d3381..83f215724f 100644 --- a/bchain/coins/vipstarcoin/vipstarcoinparser_test.go +++ b/bchain/coins/vipstarcoin/vipstarcoinparser_test.go @@ -10,8 +10,8 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) func TestMain(m *testing.M) { @@ -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/vipstarcoin/vipstarcoinrpc.go b/bchain/coins/vipstarcoin/vipstarcoinrpc.go index 1f6a9eac4c..6372720425 100644 --- a/bchain/coins/vipstarcoin/vipstarcoinrpc.go +++ b/bchain/coins/vipstarcoin/vipstarcoinrpc.go @@ -4,8 +4,8 @@ import ( "encoding/json" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) // VIPSTARCOINRPC is an interface to JSON-RPC bitcoind service. diff --git a/bchain/coins/zec/zcashparser.go b/bchain/coins/zec/zcashparser.go index ffc5ecdd2d..ed5cf5ec87 100644 --- a/bchain/coins/zec/zcashparser.go +++ b/bchain/coins/zec/zcashparser.go @@ -3,8 +3,8 @@ package zec import ( "github.com/martinboehm/btcd/wire" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) const ( diff --git a/bchain/coins/zec/zcashparser_test.go b/bchain/coins/zec/zcashparser_test.go index 3b6c6f77a5..2dee9c66a8 100644 --- a/bchain/coins/zec/zcashparser_test.go +++ b/bchain/coins/zec/zcashparser_test.go @@ -11,15 +11,15 @@ import ( "testing" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" ) var ( testTx1, testTx2 bchain.Tx - testTxPacked1 = "0a20e64aac0c211ad210c90934f06b1cc932327329e41a9f70c6eb76f79ef798b7b812ab1002000000019c012650c99d0ef761e863dbb966babf2cb7a7a2b5d90b1461c09521c473d23d000000006b483045022100f220f48c5267ef92a1e7a4d3b44fe9d97cce76eeba2785d45a0e2620b70e8d7302205640bc39e197ce19d95a98a3239af0f208ca289c067f80c97d8e411e61da5dee0121021721e83315fb5282f1d9d2a11892322df589bccd9cef45517b5fb3cfd3055c83ffffffff018eec1a3c040000001976a9149bb8229741305d8316ba3ca6a8d20740ce33c24188ac000000000162b4fc6b0000000000000000000000006ffa88c89b74f0f82e24744296845a0d0113b132ff5dfc2af34e6418eb15206af53078c4dd475cf143cd9a427983f5993622464b53e3a37d2519a946492c3977e30f0866550b9097222993a439a39260ac5e7d36aef38c7fdd1df3035a2d5817a9c20526e38f52f822d4db9d2f0156c4119d786d6e3a060ca871df7fae9a5c3a9c921b38ddc6414b13d16aa807389c68016e54bd6a9eb3b23a6bc7bf152e6dba15e9ec36f95dab15ad8f4a92a9d0309bbd930ef24bb7247bf534065c1e2f5b42e2c80eb59f48b4da6ec522319e065f8c4e463f95cc7fcad8d7ee91608e3c0ffcaa44129ba2d2da45d9a413919eca41af29faaf806a3eeb823e5a6c51afb1ec709505d812c0306bd76061a0a62d207355ad44d1ffce2b9e1dfd0818f79bd0f8e4031116b71fee2488484f17818b80532865773166cd389929e8409bb94e3948bd2e0215ef96d4e29d094590fda0de50715c11ff47c03380bb1d31b14e5b4ad8a372ca0b03364ef85f086b8a8eb5c56c3b1aee33e2cfbf1b2be1a3fb41b14b2c432b5d04d54c058fa87a96ae1d65d61b79360d09acc1e25a883fd7ae9a2a734a03362903021401c243173e1050b5cdb459b9ffc07c95e920f026618952d3a800b2e47e03b902084aed7ee8466a65d34abdbbd292781564dcd9b7440029d48c2640ebc196d4b40217f2872c1d0c1c9c2abf1147d6a5a9501895bc92960bfa182ceeb76a658224f1022bc53c4c1cd6888d72a152dc1aec5ba8a1d750fb7e498bee844d3481e4b4cd210227f94f775744185c9f24571b7df0c1c694cb2d3e4e9b955ed0b1caad2b02b5702139c4fbba03f0e422b2f3e4fc822b4f58baf32e7cd217cdbdec8540cb13d6496f271959b72a05e130eeffbe5b9a7fcd2793347cd9c0ea695265669844c363190f690c52a600cf413c3f00bdc5e9d1539e0cc63f4ec2945e0d86e6304a6deb5651e73eac21add5a641dfc95ab56200ed40d81f76755aee4659334c17ed3841ca5a5ab22f923956be1d264be2b485a0de55404510ece5c73d6626798be688f9dc18b69846acfe897a357cc4afe31f57fea32896717f124290e68f36f849fa6ecf76e02087f8c19dbc566135d7fa2daca2d843b9cc5bc3897d35f1de7d174f6407658f4a3706c12cea53d880b4d8c4d45b3f0d210214f815be49a664021a4a44b4a63e06a41d76b46f9aa6bad248e8d1a974ae7bbae5ea8ac269447db91637a19346729083cad5aebd5ff43ea13d04783068e9136da321b1152c666d2995d0ca06b26541deac62f4ef91f0e4af445b18a5c2a17c96eada0b27f85bb26dfb8f16515114c6b9f88037e2b85b3b84b65822eb99c992d99d12dcf9c71e5b46a586016faf5758483a716566db95b42187c101df68ca0554824e1c23cf0302bea03ad0a146af57e91794a268b8c82d78211718c8b5fea286f5de72fc7dfffecddcc02413525c472cb26022641d4bec2b8b7e71a7beb9ee18b82632799498eeee9a351cb9431a8d1906d5164acdf351bd538c3e9d1da8a211fe1cd18c44e72d8cdf16ce3fc9551552c05d52846ea7ef619232102588395cc2bcce509a4e7f150262a76c15475496c923dfce6bfc05871467ee7c213b39ea365c010083e0b1ba8926d3a9e586d8b11c9bab2a47d888bc7cb1a226c0086a1530e295d0047547006f4c8f1c24cdd8e16bb3845749895dec95f03fcda97d3224f6875b1b7b1c819d2fd35dd30968a3c82bc480d10082caf9d9dda8f9ec649c136c7fa07978099d97eaf4abfdc9854c266979d3cfc868f60689b6e3098b6c52a21796fe7c259d9a0dadf1b6efa59297d4c8c902febe7acf826eed30d40d2ac5119be91b51f4839d94599872c9a93c3e2691294914034001d3a278cb4a84d4ae048c0201a97e4cf1341ee663a162f5b586355018b9e5e30624ccdbeacf7d0382afacaf45f08e84d30c50bcd4e55c3138377261deb4e8c2931cd3c51cee94a048ae4839517b6e6537a5c0148d3830a33fea719ef9b4fa437e4d5fecdb646397c19ee56a0973c362a81803895cdc67246352dc566689cb203f9ebda900a5537bbb75aa25ddf3d4ab87b88737a58d760e1d271f08265daae1fe056e71971a8b826e5b215a05b71f99315b167dd2ec78874189657acafac2b5eeb9a901913f55f7ab69e1f9b203504448d414e71098b932a2309db57257eb3fef9de2f2a5a69aa46747d7b827df838345d38b95772bdab8c178c45777b92e8773864964b8e12ae29dbc1b21bf6527589f6bec71ff1cbb9928477409811c2e8150c79c3f21027ee954863b716875d3e9adfc6fdb18cd57a49bb395ca5c42da56f3beb78aad3a7a487de34a870bca61f3cdec422061328c83c910ab32ea7403c354915b7ebee29e1fea5a75158197e4a68e103f017fd7de5a70148ee7ce59356b1a74f83492e14faaa6cd4870bcc004e6eb0114d3429b74ea98fe2851b4553467a7660074e69b040aa31220d0e405d9166dbaf15e3ae2d8ec3b049ed99d17e0743bb6a1a7c3890bbdb7117f7374ad7a59aa1ab47d10445b28f4bc033794a71f88a8bf024189e9d27f9dc5859a4296437585b215656f807aca9dad35747494a43b8a1cf38be2b18a13de32a262ab29f9ba271c4fbce1a470a8243ebf9e7fd37b09262314afbb9a7e180218a0f1c9d505200028b0eb113299010a0012203dd273c42195c061140bd9b5a2a7b72cbfba66b9db63e861f70e9dc95026019c1800226b483045022100f220f48c5267ef92a1e7a4d3b44fe9d97cce76eeba2785d45a0e2620b70e8d7302205640bc39e197ce19d95a98a3239af0f208ca289c067f80c97d8e411e61da5dee0121021721e83315fb5282f1d9d2a11892322df589bccd9cef45517b5fb3cfd3055c8328ffffffff0f3a490a05043c1aec8e10001a1976a9149bb8229741305d8316ba3ca6a8d20740ce33c24188ac222374315934794c31344143486141626a656d6b647057376e594e48576e763179516244414000" - testTxPacked2 = "0a20bb47a9dd926de63e9d4f8dac58c3f63f4a079569ed3b80e932274a80f60e58b512e20101000000019cafb5c287980e6e5afb47339f6c1c81136d8255f5bd5226b36b01288494c46f000000006b483045022100c92b2f3c54918fa26288530c63a58197ea4974e5b6d92db792dd9717e6d9183c02204e577254213675466a6adad3ae6e9384cf8269fb2dd9943b86fac0c0ad8e3f98012102c99dab469e63b232488b3e7acb9cfcab7e5755f61aad318d9e06b38e5ea22880feffffff0223a7a784010000001976a914826f87806ddd4643730be99b41c98acc379e83db88ac80969800000000001976a914e395634b7684289285926d4c64db395b783720ec88ac6e75040018e4b1c9d50520eeea1128f9ea113299010a0012206fc4948428016bb32652bdf555826d13811c6c9f3347fb5a6e0e9887c2b5af9c1800226b483045022100c92b2f3c54918fa26288530c63a58197ea4974e5b6d92db792dd9717e6d9183c02204e577254213675466a6adad3ae6e9384cf8269fb2dd9943b86fac0c0ad8e3f98012102c99dab469e63b232488b3e7acb9cfcab7e5755f61aad318d9e06b38e5ea2288028feffffff0f3a490a050184a7a72310001a1976a914826f87806ddd4643730be99b41c98acc379e83db88ac22237431566d4854547770457477766f6a786f644e32435351714c596931687a59336341713a470a0398968010011a1976a914e395634b7684289285926d4c64db395b783720ec88ac222374316563784d587070685554525158474c586e56684a367563714433445a69706464674000" + testTxPacked1 = "0a20e64aac0c211ad210c90934f06b1cc932327329e41a9f70c6eb76f79ef798b7b812ab1002000000019c012650c99d0ef761e863dbb966babf2cb7a7a2b5d90b1461c09521c473d23d000000006b483045022100f220f48c5267ef92a1e7a4d3b44fe9d97cce76eeba2785d45a0e2620b70e8d7302205640bc39e197ce19d95a98a3239af0f208ca289c067f80c97d8e411e61da5dee0121021721e83315fb5282f1d9d2a11892322df589bccd9cef45517b5fb3cfd3055c83ffffffff018eec1a3c040000001976a9149bb8229741305d8316ba3ca6a8d20740ce33c24188ac000000000162b4fc6b0000000000000000000000006ffa88c89b74f0f82e24744296845a0d0113b132ff5dfc2af34e6418eb15206af53078c4dd475cf143cd9a427983f5993622464b53e3a37d2519a946492c3977e30f0866550b9097222993a439a39260ac5e7d36aef38c7fdd1df3035a2d5817a9c20526e38f52f822d4db9d2f0156c4119d786d6e3a060ca871df7fae9a5c3a9c921b38ddc6414b13d16aa807389c68016e54bd6a9eb3b23a6bc7bf152e6dba15e9ec36f95dab15ad8f4a92a9d0309bbd930ef24bb7247bf534065c1e2f5b42e2c80eb59f48b4da6ec522319e065f8c4e463f95cc7fcad8d7ee91608e3c0ffcaa44129ba2d2da45d9a413919eca41af29faaf806a3eeb823e5a6c51afb1ec709505d812c0306bd76061a0a62d207355ad44d1ffce2b9e1dfd0818f79bd0f8e4031116b71fee2488484f17818b80532865773166cd389929e8409bb94e3948bd2e0215ef96d4e29d094590fda0de50715c11ff47c03380bb1d31b14e5b4ad8a372ca0b03364ef85f086b8a8eb5c56c3b1aee33e2cfbf1b2be1a3fb41b14b2c432b5d04d54c058fa87a96ae1d65d61b79360d09acc1e25a883fd7ae9a2a734a03362903021401c243173e1050b5cdb459b9ffc07c95e920f026618952d3a800b2e47e03b902084aed7ee8466a65d34abdbbd292781564dcd9b7440029d48c2640ebc196d4b40217f2872c1d0c1c9c2abf1147d6a5a9501895bc92960bfa182ceeb76a658224f1022bc53c4c1cd6888d72a152dc1aec5ba8a1d750fb7e498bee844d3481e4b4cd210227f94f775744185c9f24571b7df0c1c694cb2d3e4e9b955ed0b1caad2b02b5702139c4fbba03f0e422b2f3e4fc822b4f58baf32e7cd217cdbdec8540cb13d6496f271959b72a05e130eeffbe5b9a7fcd2793347cd9c0ea695265669844c363190f690c52a600cf413c3f00bdc5e9d1539e0cc63f4ec2945e0d86e6304a6deb5651e73eac21add5a641dfc95ab56200ed40d81f76755aee4659334c17ed3841ca5a5ab22f923956be1d264be2b485a0de55404510ece5c73d6626798be688f9dc18b69846acfe897a357cc4afe31f57fea32896717f124290e68f36f849fa6ecf76e02087f8c19dbc566135d7fa2daca2d843b9cc5bc3897d35f1de7d174f6407658f4a3706c12cea53d880b4d8c4d45b3f0d210214f815be49a664021a4a44b4a63e06a41d76b46f9aa6bad248e8d1a974ae7bbae5ea8ac269447db91637a19346729083cad5aebd5ff43ea13d04783068e9136da321b1152c666d2995d0ca06b26541deac62f4ef91f0e4af445b18a5c2a17c96eada0b27f85bb26dfb8f16515114c6b9f88037e2b85b3b84b65822eb99c992d99d12dcf9c71e5b46a586016faf5758483a716566db95b42187c101df68ca0554824e1c23cf0302bea03ad0a146af57e91794a268b8c82d78211718c8b5fea286f5de72fc7dfffecddcc02413525c472cb26022641d4bec2b8b7e71a7beb9ee18b82632799498eeee9a351cb9431a8d1906d5164acdf351bd538c3e9d1da8a211fe1cd18c44e72d8cdf16ce3fc9551552c05d52846ea7ef619232102588395cc2bcce509a4e7f150262a76c15475496c923dfce6bfc05871467ee7c213b39ea365c010083e0b1ba8926d3a9e586d8b11c9bab2a47d888bc7cb1a226c0086a1530e295d0047547006f4c8f1c24cdd8e16bb3845749895dec95f03fcda97d3224f6875b1b7b1c819d2fd35dd30968a3c82bc480d10082caf9d9dda8f9ec649c136c7fa07978099d97eaf4abfdc9854c266979d3cfc868f60689b6e3098b6c52a21796fe7c259d9a0dadf1b6efa59297d4c8c902febe7acf826eed30d40d2ac5119be91b51f4839d94599872c9a93c3e2691294914034001d3a278cb4a84d4ae048c0201a97e4cf1341ee663a162f5b586355018b9e5e30624ccdbeacf7d0382afacaf45f08e84d30c50bcd4e55c3138377261deb4e8c2931cd3c51cee94a048ae4839517b6e6537a5c0148d3830a33fea719ef9b4fa437e4d5fecdb646397c19ee56a0973c362a81803895cdc67246352dc566689cb203f9ebda900a5537bbb75aa25ddf3d4ab87b88737a58d760e1d271f08265daae1fe056e71971a8b826e5b215a05b71f99315b167dd2ec78874189657acafac2b5eeb9a901913f55f7ab69e1f9b203504448d414e71098b932a2309db57257eb3fef9de2f2a5a69aa46747d7b827df838345d38b95772bdab8c178c45777b92e8773864964b8e12ae29dbc1b21bf6527589f6bec71ff1cbb9928477409811c2e8150c79c3f21027ee954863b716875d3e9adfc6fdb18cd57a49bb395ca5c42da56f3beb78aad3a7a487de34a870bca61f3cdec422061328c83c910ab32ea7403c354915b7ebee29e1fea5a75158197e4a68e103f017fd7de5a70148ee7ce59356b1a74f83492e14faaa6cd4870bcc004e6eb0114d3429b74ea98fe2851b4553467a7660074e69b040aa31220d0e405d9166dbaf15e3ae2d8ec3b049ed99d17e0743bb6a1a7c3890bbdb7117f7374ad7a59aa1ab47d10445b28f4bc033794a71f88a8bf024189e9d27f9dc5859a4296437585b215656f807aca9dad35747494a43b8a1cf38be2b18a13de32a262ab29f9ba271c4fbce1a470a8243ebf9e7fd37b09262314afbb9a7e180218a0f1c9d50528b0eb1132950112203dd273c42195c061140bd9b5a2a7b72cbfba66b9db63e861f70e9dc95026019c226b483045022100f220f48c5267ef92a1e7a4d3b44fe9d97cce76eeba2785d45a0e2620b70e8d7302205640bc39e197ce19d95a98a3239af0f208ca289c067f80c97d8e411e61da5dee0121021721e83315fb5282f1d9d2a11892322df589bccd9cef45517b5fb3cfd3055c8328ffffffff0f3a470a05043c1aec8e1a1976a9149bb8229741305d8316ba3ca6a8d20740ce33c24188ac222374315934794c31344143486141626a656d6b647057376e594e48576e76317951624441" + testTxPacked2 = "0a20bb47a9dd926de63e9d4f8dac58c3f63f4a079569ed3b80e932274a80f60e58b512e20101000000019cafb5c287980e6e5afb47339f6c1c81136d8255f5bd5226b36b01288494c46f000000006b483045022100c92b2f3c54918fa26288530c63a58197ea4974e5b6d92db792dd9717e6d9183c02204e577254213675466a6adad3ae6e9384cf8269fb2dd9943b86fac0c0ad8e3f98012102c99dab469e63b232488b3e7acb9cfcab7e5755f61aad318d9e06b38e5ea22880feffffff0223a7a784010000001976a914826f87806ddd4643730be99b41c98acc379e83db88ac80969800000000001976a914e395634b7684289285926d4c64db395b783720ec88ac6e75040018e4b1c9d50520eeea1128f9ea1132950112206fc4948428016bb32652bdf555826d13811c6c9f3347fb5a6e0e9887c2b5af9c226b483045022100c92b2f3c54918fa26288530c63a58197ea4974e5b6d92db792dd9717e6d9183c02204e577254213675466a6adad3ae6e9384cf8269fb2dd9943b86fac0c0ad8e3f98012102c99dab469e63b232488b3e7acb9cfcab7e5755f61aad318d9e06b38e5ea2288028feffffff0f3a470a050184a7a7231a1976a914826f87806ddd4643730be99b41c98acc379e83db88ac22237431566d4854547770457477766f6a786f644e32435351714c596931687a59336341713a470a0398968010011a1976a914e395634b7684289285926d4c64db395b783720ec88ac222374316563784d587070685554525158474c586e56684a367563714433445a6970646467" ) func init() { diff --git a/bchain/coins/zec/zcashrpc.go b/bchain/coins/zec/zcashrpc.go index eb9d6dd850..c1952c6c4e 100644 --- a/bchain/coins/zec/zcashrpc.go +++ b/bchain/coins/zec/zcashrpc.go @@ -1,13 +1,16 @@ package zec import ( + "bytes" "encoding/json" + "reflect" + "strings" "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/common" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/common" ) // ZCashRPC is an interface to JSON-RPC bitcoind service @@ -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 new file mode 100644 index 0000000000..8eb94f54a1 --- /dev/null +++ b/bchain/evm_interface.go @@ -0,0 +1,81 @@ +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 +type EVMClient interface { + NetworkID(ctx context.Context) (*big.Int, error) + HeaderByNumber(ctx context.Context, number *big.Int) (EVMHeader, error) + SuggestGasPrice(ctx context.Context) (*big.Int, error) + EstimateGas(ctx context.Context, msg interface{}) (uint64, error) + BalanceAt(ctx context.Context, addrDesc AddressDescriptor, blockNumber *big.Int) (*big.Int, error) + NonceAt(ctx context.Context, addrDesc AddressDescriptor, blockNumber *big.Int) (uint64, error) +} + +// EVMRPCClient provides the necessary rpc functionality for evm chain sync +type EVMRPCClient interface { + EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (EVMClientSubscription, error) + CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error + Close() +} + +// EVMHeader provides access to the necessary header data for evm chain sync +type EVMHeader interface { + Hash() string + Number() *big.Int + Difficulty() *big.Int +} + +// EVMHash provides access to the necessary hash data for evm chain sync +type EVMHash interface { + Hex() string +} + +// EVMClientSubscription provides interaction with an evm client subscription +type EVMClientSubscription interface { + Err() <-chan error + Unsubscribe() +} + +// EVMSubscriber provides interaction with a subscription channel +type EVMSubscriber interface { + Channel() interface{} + Close() +} + +// EVMNewBlockSubscriber provides interaction with a new block subscription channel +type EVMNewBlockSubscriber interface { + EVMSubscriber + Read() (EVMHeader, bool) +} + +// EVMNewBlockSubscriber provides interaction with a new tx subscription channel +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 29946b0229..e684006bae 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,114 @@ 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 + AssetInfo *AssetInfo // SYSCOIN +} + +func newResyncOutpointCache(sizeHint int) *resyncOutpointCache { + return &resyncOutpointCache{entries: make(map[Outpoint]outpointInfo, sizeHint)} +} + +func (c *resyncOutpointCache) get(outpoint Outpoint) (AddressDescriptor, *big.Int, *AssetInfo, 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, nil, false + } + atomic.AddUint64(&c.hits, 1) + return entry.addrDesc, entry.value, entry.AssetInfo, true +} + +func (c *resyncOutpointCache) set(outpoint Outpoint, addrDesc AddressDescriptor, value *big.Int, assetInfo *AssetInfo) { + 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...) + var assetInfoCopy *AssetInfo + if assetInfo != nil { + assetInfoCopy = &AssetInfo{ + AssetGuid: assetInfo.AssetGuid, + } + if assetInfo.ValueSat != nil { + assetInfoCopy.ValueSat = new(big.Int).Set(assetInfo.ValueSat) + } + } + c.mu.Lock() + c.entries[outpoint] = outpointInfo{addrDesc: addrCopy, value: valueCopy, AssetInfo: assetInfoCopy} + 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 +138,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,13 +151,40 @@ 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 assetInfo *AssetInfo = nil + var assetInfo *AssetInfo var value *big.Int vin := &payload.tx.Vin[payload.index] + if vin.Txid == "" { + // 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, assetInfo = m.AddrDescForOutpoint(outpoint) + } + if addrDesc == nil { + if cache != nil { + if cachedDesc, cachedValue, cachedAssetInfo, ok := cache.get(outpoint); ok { + addrDesc = cachedDesc + value = cachedValue + assetInfo = cachedAssetInfo + } + } } if addrDesc == nil { itx, err := m.chain.GetTransactionForMempool(vin.Txid) @@ -75,40 +196,91 @@ 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, output.AssetInfo) + if output.N == vin.Vout { + found = true + addrDesc = outDesc + value = &output.ValueSat + assetInfo = output.AssetInfo + } + } + 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 + assetInfo = itx.Vout[vin.Vout].AssetInfo } - assetInfo = itx.Vout[vin.Vout].AssetInfo - value = &itx.Vout[vin.Vout].ValueSat } vin.AddrDesc = addrDesc vin.ValueSat = *value - return &addrIndex{string(addrDesc), ^int32(vin.Vout), assetInfo} + vin.AssetInfo = assetInfo + return &addrIndex{addrDesc: string(addrDesc), n: ^int32(vin.Vout), AssetInfo: assetInfo} } -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 len(addrDesc) > 0 { - io = append(io, addrIndex{string(addrDesc), int32(output.N), output.AssetInfo}) + if cache != nil { + cache.set(Outpoint{txid, int32(output.N)}, addrDesc, &output.ValueSat, output.AssetInfo) } - if m.OnNewTxAddr != nil { - m.OnNewTxAddr(tx, addrDesc) + if len(addrDesc) > 0 { + io = append(io, addrIndex{addrDesc: string(addrDesc), n: int32(output.N), AssetInfo: output.AssetInfo}) } } dispatched := 0 @@ -140,22 +312,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 { @@ -168,31 +518,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 { @@ -202,6 +562,23 @@ func (m *MempoolBitcoinType) Resync() (int, error) { m.mux.Unlock() } } - glog.Info("mempool: resync, ", 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 d9d80756b5..85217880ec 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, @@ -40,7 +40,7 @@ func appendAddress(io []addrIndex, i int32, a string, parser BlockChainParser) ( glog.Error("error in input addrDesc in ", a, ": ", err) return io, nil } - io = append(io, addrIndex{string(addrDesc), i, nil}) + io = append(io, addrIndex{addrDesc: string(addrDesc), n: i}) } return io, addrDesc } @@ -65,7 +65,7 @@ func (m *MempoolEthereumType) createTxEntry(txid string, txTime uint32) (txEntry continue } if len(addrDesc) > 0 { - addrIndexes = append(addrIndexes, addrIndex{string(addrDesc), int32(output.N), nil}) + addrIndexes = append(addrIndexes, addrIndex{addrDesc: string(addrDesc), n: int32(output.N)}) } } for j := range mtx.Vin { @@ -74,25 +74,16 @@ func (m *MempoolEthereumType) createTxEntry(txid string, txTime uint32) (txEntry addrIndexes, input.AddrDesc = appendAddress(addrIndexes, ^int32(i), a, parser) } } - t, err := parser.EthereumTypeGetErc20FromTx(tx) + t, err := parser.EthereumTypeGetTokenTransfersFromTx(tx) if err != nil { - glog.Error("GetErc20FromTx for tx ", txid, ", ", err) + glog.Error("GetGetTokenTransfersFromTx for tx ", txid, ", ", err) } else { - mtx.Erc20 = t + mtx.TokenTransfers = t for i := range t { addrIndexes, _ = appendAddress(addrIndexes, ^int32(i+1), t[i].From, parser) 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 61f692257b..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 } @@ -78,7 +88,6 @@ func (mq *MQ) run(callback func(NotificationType)) { msg, err := mq.socket.RecvMessageBytes(0) if err != nil { if zmq.AsErrno(err) == zmq.Errno(zmq.ETERM) || err.Error() == "Socket is closed" { - glog.Info("MQ loop terminated error ", err, ", ", zmq.AsErrno(err)) break } // suppress logging of error for the first time @@ -93,15 +102,14 @@ func (mq *MQ) run(callback func(NotificationType)) { } else { repeatedError = false } - if msg != nil && 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 - break - case "hashtx": + case mq.subs.TxReceive: nt = NotificationNewTx - break default: nt = NotificationUnknown glog.Infof("MQ: NotificationUnknown %v", string(msg[0])) @@ -124,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/tx.pb.go b/bchain/tx.pb.go index b401faefa8..e17d8f8dfa 100644 --- a/bchain/tx.pb.go +++ b/bchain/tx.pb.go @@ -1,230 +1,426 @@ // Code generated by protoc-gen-go. DO NOT EDIT. -// source: tx.proto +// versions: +// protoc-gen-go v1.28.1 +// protoc v3.21.5 +// source: bchain/tx.proto -/* -Package bchain is a generated protocol buffer package. - -It is generated from these files: - tx.proto - -It has these top-level messages: - ProtoTransaction -*/ package bchain -import proto "github.com/golang/protobuf/proto" -import fmt "fmt" -import math "math" +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) -// Reference imports to suppress errors if they are not otherwise used. -var _ = proto.Marshal -var _ = fmt.Errorf -var _ = math.Inf - -// 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 +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) +) type ProtoTransaction struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + Txid []byte `protobuf:"bytes,1,opt,name=Txid,proto3" json:"Txid,omitempty"` Hex []byte `protobuf:"bytes,2,opt,name=Hex,proto3" json:"Hex,omitempty"` - Blocktime uint64 `protobuf:"varint,3,opt,name=Blocktime" json:"Blocktime,omitempty"` - Locktime uint32 `protobuf:"varint,4,opt,name=Locktime" json:"Locktime,omitempty"` - Height uint32 `protobuf:"varint,5,opt,name=Height" json:"Height,omitempty"` - Vin []*ProtoTransaction_VinType `protobuf:"bytes,6,rep,name=Vin" json:"Vin,omitempty"` - Vout []*ProtoTransaction_VoutType `protobuf:"bytes,7,rep,name=Vout" json:"Vout,omitempty"` - Version int32 `protobuf:"varint,8,opt,name=Version" json:"Version,omitempty"` + Blocktime uint64 `protobuf:"varint,3,opt,name=Blocktime,proto3" json:"Blocktime,omitempty"` + Locktime uint32 `protobuf:"varint,4,opt,name=Locktime,proto3" json:"Locktime,omitempty"` + Height uint32 `protobuf:"varint,5,opt,name=Height,proto3" json:"Height,omitempty"` + Vin []*ProtoTransaction_VinType `protobuf:"bytes,6,rep,name=Vin,proto3" json:"Vin,omitempty"` + Vout []*ProtoTransaction_VoutType `protobuf:"bytes,7,rep,name=Vout,proto3" json:"Vout,omitempty"` + Version int32 `protobuf:"varint,8,opt,name=Version,proto3" json:"Version,omitempty"` + VSize int64 `protobuf:"varint,9,opt,name=VSize,proto3" json:"VSize,omitempty"` +} + +func (x *ProtoTransaction) Reset() { + *x = ProtoTransaction{} + if protoimpl.UnsafeEnabled { + mi := &file_bchain_tx_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProtoTransaction) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProtoTransaction) ProtoMessage() {} + +func (x *ProtoTransaction) ProtoReflect() protoreflect.Message { + mi := &file_bchain_tx_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) } -func (m *ProtoTransaction) Reset() { *m = ProtoTransaction{} } -func (m *ProtoTransaction) String() string { return proto.CompactTextString(m) } -func (*ProtoTransaction) ProtoMessage() {} -func (*ProtoTransaction) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } +// Deprecated: Use ProtoTransaction.ProtoReflect.Descriptor instead. +func (*ProtoTransaction) Descriptor() ([]byte, []int) { + return file_bchain_tx_proto_rawDescGZIP(), []int{0} +} -func (m *ProtoTransaction) GetTxid() []byte { - if m != nil { - return m.Txid +func (x *ProtoTransaction) GetTxid() []byte { + if x != nil { + return x.Txid } return nil } -func (m *ProtoTransaction) GetHex() []byte { - if m != nil { - return m.Hex +func (x *ProtoTransaction) GetHex() []byte { + if x != nil { + return x.Hex } return nil } -func (m *ProtoTransaction) GetBlocktime() uint64 { - if m != nil { - return m.Blocktime +func (x *ProtoTransaction) GetBlocktime() uint64 { + if x != nil { + return x.Blocktime } return 0 } -func (m *ProtoTransaction) GetLocktime() uint32 { - if m != nil { - return m.Locktime +func (x *ProtoTransaction) GetLocktime() uint32 { + if x != nil { + return x.Locktime } return 0 } -func (m *ProtoTransaction) GetHeight() uint32 { - if m != nil { - return m.Height +func (x *ProtoTransaction) GetHeight() uint32 { + if x != nil { + return x.Height } return 0 } -func (m *ProtoTransaction) GetVin() []*ProtoTransaction_VinType { - if m != nil { - return m.Vin +func (x *ProtoTransaction) GetVin() []*ProtoTransaction_VinType { + if x != nil { + return x.Vin } return nil } -func (m *ProtoTransaction) GetVout() []*ProtoTransaction_VoutType { - if m != nil { - return m.Vout +func (x *ProtoTransaction) GetVout() []*ProtoTransaction_VoutType { + if x != nil { + return x.Vout } return nil } -func (m *ProtoTransaction) GetVersion() int32 { - if m != nil { - return m.Version +func (x *ProtoTransaction) GetVersion() int32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *ProtoTransaction) GetVSize() int64 { + if x != nil { + return x.VSize } return 0 } type ProtoTransaction_VinType struct { - Coinbase string `protobuf:"bytes,1,opt,name=Coinbase" json:"Coinbase,omitempty"` + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Coinbase string `protobuf:"bytes,1,opt,name=Coinbase,proto3" json:"Coinbase,omitempty"` Txid []byte `protobuf:"bytes,2,opt,name=Txid,proto3" json:"Txid,omitempty"` - Vout uint32 `protobuf:"varint,3,opt,name=Vout" json:"Vout,omitempty"` + Vout uint32 `protobuf:"varint,3,opt,name=Vout,proto3" json:"Vout,omitempty"` ScriptSigHex []byte `protobuf:"bytes,4,opt,name=ScriptSigHex,proto3" json:"ScriptSigHex,omitempty"` - Sequence uint32 `protobuf:"varint,5,opt,name=Sequence" json:"Sequence,omitempty"` - Addresses []string `protobuf:"bytes,6,rep,name=Addresses" json:"Addresses,omitempty"` + Sequence uint32 `protobuf:"varint,5,opt,name=Sequence,proto3" json:"Sequence,omitempty"` + Addresses []string `protobuf:"bytes,6,rep,name=Addresses,proto3" json:"Addresses,omitempty"` } -func (m *ProtoTransaction_VinType) Reset() { *m = ProtoTransaction_VinType{} } -func (m *ProtoTransaction_VinType) String() string { return proto.CompactTextString(m) } -func (*ProtoTransaction_VinType) ProtoMessage() {} -func (*ProtoTransaction_VinType) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0, 0} } +func (x *ProtoTransaction_VinType) Reset() { + *x = ProtoTransaction_VinType{} + if protoimpl.UnsafeEnabled { + mi := &file_bchain_tx_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ProtoTransaction_VinType) String() string { + return protoimpl.X.MessageStringOf(x) +} -func (m *ProtoTransaction_VinType) GetCoinbase() string { - if m != nil { - return m.Coinbase +func (*ProtoTransaction_VinType) ProtoMessage() {} + +func (x *ProtoTransaction_VinType) ProtoReflect() protoreflect.Message { + mi := &file_bchain_tx_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtoTransaction_VinType.ProtoReflect.Descriptor instead. +func (*ProtoTransaction_VinType) Descriptor() ([]byte, []int) { + return file_bchain_tx_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *ProtoTransaction_VinType) GetCoinbase() string { + if x != nil { + return x.Coinbase } return "" } -func (m *ProtoTransaction_VinType) GetTxid() []byte { - if m != nil { - return m.Txid +func (x *ProtoTransaction_VinType) GetTxid() []byte { + if x != nil { + return x.Txid } return nil } -func (m *ProtoTransaction_VinType) GetVout() uint32 { - if m != nil { - return m.Vout +func (x *ProtoTransaction_VinType) GetVout() uint32 { + if x != nil { + return x.Vout } return 0 } -func (m *ProtoTransaction_VinType) GetScriptSigHex() []byte { - if m != nil { - return m.ScriptSigHex +func (x *ProtoTransaction_VinType) GetScriptSigHex() []byte { + if x != nil { + return x.ScriptSigHex } return nil } -func (m *ProtoTransaction_VinType) GetSequence() uint32 { - if m != nil { - return m.Sequence +func (x *ProtoTransaction_VinType) GetSequence() uint32 { + if x != nil { + return x.Sequence } return 0 } -func (m *ProtoTransaction_VinType) GetAddresses() []string { - if m != nil { - return m.Addresses +func (x *ProtoTransaction_VinType) GetAddresses() []string { + if x != nil { + return x.Addresses } return nil } type ProtoTransaction_VoutType struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + ValueSat []byte `protobuf:"bytes,1,opt,name=ValueSat,proto3" json:"ValueSat,omitempty"` - N uint32 `protobuf:"varint,2,opt,name=N" json:"N,omitempty"` + N uint32 `protobuf:"varint,2,opt,name=N,proto3" json:"N,omitempty"` ScriptPubKeyHex []byte `protobuf:"bytes,3,opt,name=ScriptPubKeyHex,proto3" json:"ScriptPubKeyHex,omitempty"` - Addresses []string `protobuf:"bytes,4,rep,name=Addresses" json:"Addresses,omitempty"` + Addresses []string `protobuf:"bytes,4,rep,name=Addresses,proto3" json:"Addresses,omitempty"` } -func (m *ProtoTransaction_VoutType) Reset() { *m = ProtoTransaction_VoutType{} } -func (m *ProtoTransaction_VoutType) String() string { return proto.CompactTextString(m) } -func (*ProtoTransaction_VoutType) ProtoMessage() {} -func (*ProtoTransaction_VoutType) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0, 1} } +func (x *ProtoTransaction_VoutType) Reset() { + *x = ProtoTransaction_VoutType{} + if protoimpl.UnsafeEnabled { + mi := &file_bchain_tx_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} -func (m *ProtoTransaction_VoutType) GetValueSat() []byte { - if m != nil { - return m.ValueSat +func (x *ProtoTransaction_VoutType) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProtoTransaction_VoutType) ProtoMessage() {} + +func (x *ProtoTransaction_VoutType) ProtoReflect() protoreflect.Message { + mi := &file_bchain_tx_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProtoTransaction_VoutType.ProtoReflect.Descriptor instead. +func (*ProtoTransaction_VoutType) Descriptor() ([]byte, []int) { + return file_bchain_tx_proto_rawDescGZIP(), []int{0, 1} +} + +func (x *ProtoTransaction_VoutType) GetValueSat() []byte { + if x != nil { + return x.ValueSat } return nil } -func (m *ProtoTransaction_VoutType) GetN() uint32 { - if m != nil { - return m.N +func (x *ProtoTransaction_VoutType) GetN() uint32 { + if x != nil { + return x.N } return 0 } -func (m *ProtoTransaction_VoutType) GetScriptPubKeyHex() []byte { - if m != nil { - return m.ScriptPubKeyHex +func (x *ProtoTransaction_VoutType) GetScriptPubKeyHex() []byte { + if x != nil { + return x.ScriptPubKeyHex } return nil } -func (m *ProtoTransaction_VoutType) GetAddresses() []string { - if m != nil { - return m.Addresses +func (x *ProtoTransaction_VoutType) GetAddresses() []string { + if x != nil { + return x.Addresses } return nil } -func init() { - proto.RegisterType((*ProtoTransaction)(nil), "bchain.ProtoTransaction") - proto.RegisterType((*ProtoTransaction_VinType)(nil), "bchain.ProtoTransaction.VinType") - proto.RegisterType((*ProtoTransaction_VoutType)(nil), "bchain.ProtoTransaction.VoutType") -} - -func init() { proto.RegisterFile("tx.proto", fileDescriptor0) } - -var fileDescriptor0 = []byte{ - // 346 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x74, 0x52, 0x41, 0x6e, 0xea, 0x30, - 0x14, 0x94, 0x89, 0x09, 0xe1, 0x7d, 0xd0, 0x47, 0x5e, 0x54, 0x16, 0xea, 0x22, 0x65, 0x95, 0x55, - 0x16, 0x54, 0x3d, 0x40, 0xdb, 0x0d, 0x52, 0x2b, 0x84, 0x1c, 0x94, 0x7d, 0x12, 0x2c, 0xb0, 0x4a, - 0x6d, 0x9a, 0x38, 0x12, 0x48, 0xbd, 0x51, 0x8f, 0xd0, 0xcb, 0x55, 0x7e, 0x84, 0x50, 0x90, 0xba, - 0xf3, 0x8c, 0xdf, 0x64, 0xe6, 0x4d, 0x0c, 0x81, 0xdd, 0xc7, 0xbb, 0xd2, 0x58, 0xc3, 0xfc, 0xbc, - 0xd8, 0x64, 0x4a, 0x4f, 0xbe, 0x29, 0x8c, 0x16, 0x8e, 0x59, 0x96, 0x99, 0xae, 0xb2, 0xc2, 0x2a, - 0xa3, 0x19, 0x03, 0xba, 0xdc, 0xab, 0x15, 0x27, 0x21, 0x89, 0x06, 0x02, 0xcf, 0x6c, 0x04, 0xde, - 0x4c, 0xee, 0x79, 0x07, 0x29, 0x77, 0x64, 0xb7, 0xd0, 0x7f, 0xda, 0x9a, 0xe2, 0xcd, 0xaa, 0x77, - 0xc9, 0xbd, 0x90, 0x44, 0x54, 0x9c, 0x09, 0x36, 0x86, 0xe0, 0xf5, 0x74, 0x49, 0x43, 0x12, 0x0d, - 0x45, 0x8b, 0xd9, 0x0d, 0xf8, 0x33, 0xa9, 0xd6, 0x1b, 0xcb, 0xbb, 0x78, 0xd3, 0x20, 0x36, 0x05, - 0x2f, 0x55, 0x9a, 0xfb, 0xa1, 0x17, 0xfd, 0x9b, 0x86, 0xf1, 0x31, 0x62, 0x7c, 0x1d, 0x2f, 0x4e, - 0x95, 0x5e, 0x1e, 0x76, 0x52, 0xb8, 0x61, 0xf6, 0x00, 0x34, 0x35, 0xb5, 0xe5, 0x3d, 0x14, 0xdd, - 0xfd, 0x2d, 0x32, 0xb5, 0x45, 0x15, 0x8e, 0x33, 0x0e, 0xbd, 0x54, 0x96, 0x95, 0x32, 0x9a, 0x07, - 0x21, 0x89, 0xba, 0xe2, 0x04, 0xc7, 0x5f, 0x04, 0x7a, 0x8d, 0x83, 0x5b, 0xe2, 0xd9, 0x28, 0x9d, - 0x67, 0x95, 0xc4, 0x32, 0xfa, 0xa2, 0xc5, 0x6d, 0x49, 0x9d, 0x5f, 0x25, 0xb1, 0x26, 0x8c, 0x87, - 0x6b, 0x1d, 0x9d, 0x26, 0x30, 0x48, 0x8a, 0x52, 0xed, 0x6c, 0xa2, 0xd6, 0xae, 0x41, 0x8a, 0xf3, - 0x17, 0x9c, 0xf3, 0x49, 0xe4, 0x47, 0x2d, 0x75, 0x21, 0x9b, 0x4a, 0x5a, 0xec, 0x6a, 0x7e, 0x5c, - 0xad, 0x4a, 0x59, 0x55, 0xb2, 0xc2, 0x6a, 0xfa, 0xe2, 0x4c, 0x8c, 0x3f, 0x21, 0x38, 0x6d, 0xe6, - 0xbe, 0x92, 0x66, 0xdb, 0x5a, 0x26, 0x99, 0x6d, 0x7e, 0x5d, 0x8b, 0xd9, 0x00, 0xc8, 0x1c, 0xa3, - 0x0e, 0x05, 0x99, 0xb3, 0x08, 0xfe, 0x1f, 0xfd, 0x17, 0x75, 0xfe, 0x22, 0x0f, 0x2e, 0x96, 0x87, - 0x82, 0x6b, 0xfa, 0xd2, 0x9d, 0x5e, 0xb9, 0xe7, 0x3e, 0x3e, 0xa6, 0xfb, 0x9f, 0x00, 0x00, 0x00, - 0xff, 0xff, 0xa1, 0x51, 0x2e, 0xba, 0x58, 0x02, 0x00, 0x00, -} \ No newline at end of file +var File_bchain_tx_proto protoreflect.FileDescriptor + +var file_bchain_tx_proto_rawDesc = []byte{ + 0x0a, 0x0f, 0x62, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x2f, 0x74, 0x78, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x12, 0x06, 0x62, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x22, 0xd1, 0x04, 0x0a, 0x10, 0x50, 0x72, + 0x6f, 0x74, 0x6f, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, + 0x0a, 0x04, 0x54, 0x78, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x54, 0x78, + 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x48, 0x65, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x03, 0x48, 0x65, 0x78, 0x12, 0x1c, 0x0a, 0x09, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x74, 0x69, 0x6d, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x74, 0x69, + 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x4c, 0x6f, 0x63, 0x6b, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x4c, 0x6f, 0x63, 0x6b, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x16, + 0x0a, 0x06, 0x48, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, + 0x48, 0x65, 0x69, 0x67, 0x68, 0x74, 0x12, 0x32, 0x0a, 0x03, 0x56, 0x69, 0x6e, 0x18, 0x06, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x62, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x2e, 0x50, 0x72, 0x6f, + 0x74, 0x6f, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x56, 0x69, + 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x03, 0x56, 0x69, 0x6e, 0x12, 0x35, 0x0a, 0x04, 0x56, 0x6f, + 0x75, 0x74, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x62, 0x63, 0x68, 0x61, 0x69, + 0x6e, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x2e, 0x56, 0x6f, 0x75, 0x74, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x56, 0x6f, 0x75, + 0x74, 0x12, 0x18, 0x0a, 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x05, 0x52, 0x07, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x56, + 0x53, 0x69, 0x7a, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x56, 0x53, 0x69, 0x7a, + 0x65, 0x1a, 0xab, 0x01, 0x0a, 0x07, 0x56, 0x69, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1a, 0x0a, + 0x08, 0x43, 0x6f, 0x69, 0x6e, 0x62, 0x61, 0x73, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x43, 0x6f, 0x69, 0x6e, 0x62, 0x61, 0x73, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x78, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x54, 0x78, 0x69, 0x64, 0x12, 0x12, 0x0a, + 0x04, 0x56, 0x6f, 0x75, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x56, 0x6f, 0x75, + 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x53, 0x69, 0x67, 0x48, 0x65, + 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0c, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x53, + 0x69, 0x67, 0x48, 0x65, 0x78, 0x12, 0x1a, 0x0a, 0x08, 0x53, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, + 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x53, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, + 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x06, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x1a, + 0x7c, 0x0a, 0x08, 0x56, 0x6f, 0x75, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x53, 0x61, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x56, + 0x61, 0x6c, 0x75, 0x65, 0x53, 0x61, 0x74, 0x12, 0x0c, 0x0a, 0x01, 0x4e, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x01, 0x4e, 0x12, 0x28, 0x0a, 0x0f, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x50, + 0x75, 0x62, 0x4b, 0x65, 0x79, 0x48, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, + 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x48, 0x65, 0x78, 0x12, + 0x1c, 0x0a, 0x09, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x09, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x42, 0x09, 0x5a, + 0x07, 0x62, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x2f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_bchain_tx_proto_rawDescOnce sync.Once + file_bchain_tx_proto_rawDescData = file_bchain_tx_proto_rawDesc +) + +func file_bchain_tx_proto_rawDescGZIP() []byte { + file_bchain_tx_proto_rawDescOnce.Do(func() { + file_bchain_tx_proto_rawDescData = protoimpl.X.CompressGZIP(file_bchain_tx_proto_rawDescData) + }) + return file_bchain_tx_proto_rawDescData +} + +var file_bchain_tx_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_bchain_tx_proto_goTypes = []interface{}{ + (*ProtoTransaction)(nil), // 0: bchain.ProtoTransaction + (*ProtoTransaction_VinType)(nil), // 1: bchain.ProtoTransaction.VinType + (*ProtoTransaction_VoutType)(nil), // 2: bchain.ProtoTransaction.VoutType +} +var file_bchain_tx_proto_depIdxs = []int32{ + 1, // 0: bchain.ProtoTransaction.Vin:type_name -> bchain.ProtoTransaction.VinType + 2, // 1: bchain.ProtoTransaction.Vout:type_name -> bchain.ProtoTransaction.VoutType + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_bchain_tx_proto_init() } +func file_bchain_tx_proto_init() { + if File_bchain_tx_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_bchain_tx_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProtoTransaction); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_bchain_tx_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProtoTransaction_VinType); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_bchain_tx_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*ProtoTransaction_VoutType); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_bchain_tx_proto_rawDesc, + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_bchain_tx_proto_goTypes, + DependencyIndexes: file_bchain_tx_proto_depIdxs, + MessageInfos: file_bchain_tx_proto_msgTypes, + }.Build() + File_bchain_tx_proto = out.File + file_bchain_tx_proto_rawDesc = nil + file_bchain_tx_proto_goTypes = nil + file_bchain_tx_proto_depIdxs = nil +} diff --git a/bchain/tx.proto b/bchain/tx.proto index cd5c7bc559..d64e844583 100644 --- a/bchain/tx.proto +++ b/bchain/tx.proto @@ -1,6 +1,7 @@ syntax = "proto3"; package bchain; - + option go_package = "bchain/"; + message ProtoTransaction { message VinType { string Coinbase = 1; @@ -24,4 +25,5 @@ syntax = "proto3"; repeated VinType Vin = 6; repeated VoutType Vout = 7; int32 Version = 8; + int64 VSize = 9; } \ No newline at end of file diff --git a/bchain/types.go b/bchain/types.go index 99df1c21d3..acee4a1824 100644 --- a/bchain/types.go +++ b/bchain/types.go @@ -7,12 +7,9 @@ import ( "errors" "fmt" "math/big" - "unsafe" - "bytes" - "github.com/golang/glog" - "github.com/syscoin/syscoinwire/syscoin/wire" - "github.com/syscoin/blockbook/common" + syscoinwire "github.com/syscoin/syscoinwire/syscoin/wire" + "github.com/trezor/blockbook/common" ) // ChainType is type of the blockchain @@ -43,156 +40,223 @@ 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"` -} - -type AssetInfo struct { - AssetGuid uint64 `json:"assetGuid,omitempty"` - ValueSat *big.Int `json:"valueSat,omitempty"` + 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"` - AssetInfo *AssetInfo `json:"assetInfo,omitempty"` + 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"` - AssetInfo *AssetInfo `json:"assetInfo,omitempty"` + 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."` + // SYSCOIN: SPT allocation metadata attached to this UTXO. + AssetInfo *AssetInfo `json:"assetInfo,omitempty" ts_doc:"Syscoin SPT asset metadata for this output."` } // 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"` - 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:"-"` - Memo []byte `json:"memo,omitempty"` + 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)."` + // SYSCOIN: SPT memo decoded from allocation OP_RETURN data. + Memo []byte `json:"memo,omitempty"` } -// 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 - AssetInfo *AssetInfo `json:"assetInfo,omitempty"` + 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."` + // SYSCOIN: SPT metadata on the spent output. + AssetInfo *AssetInfo `json:"assetInfo,omitempty" ts_doc:"Syscoin SPT asset metadata for this 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"` - Vin []MempoolVin `json:"vin"` - Vout []Vout `json:"vout"` - Blocktime int64 `json:"blocktime,omitempty"` - Erc20 []Erc20Transfer `json:"-"` - CoinSpecificData interface{} `json:"-"` - Memo []byte `json:"memo,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 (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)."` +} + +// SYSCOIN: AssetInfo holds SPT allocation metadata attached to transaction +// inputs, outputs, and UTXOs. ValueSat is in the asset's base unit. +type AssetInfo struct { + AssetGuid uint64 + ValueSat *big.Int +} + +// TokenStandard - standard of token +type TokenStandard int + +// TokenStandard enumeration +const ( + FungibleToken = TokenStandard(iota) // ERC20/BEP20 + NonFungibleToken // ERC721/BEP721 + MultiToken // ERC1155/BEP1155 +) + +// TokenStandardName specifies standard of token +type TokenStandardName string + +// SYSCOIN: keep the historical Syscoin parser naming while using upstream's +// token-standard string type underneath. +type TokenType = TokenStandardName + +// Token standards +const ( + UnknownTokenStandard TokenStandardName = "" + UnhandledTokenStandard TokenStandardName = "-" + + // XPUBAddressStandard is address derived from xpub + XPUBAddressStandard TokenStandardName = "XPUBAddress" + + // SYSCOIN: SPT token standards exposed through the generic token fields. + SPTTokenStandard TokenStandardName = "SPTAllocated" + SPTAssetAllocationMintStandard TokenStandardName = "SPTAssetAllocationMint" + SPTAssetAllocationSendStandard TokenStandardName = "SPTAssetAllocationSend" + SPTAssetSyscoinBurnToAllocationStandard TokenStandardName = "SPTSyscoinBurnToAssetAllocation" + SPTAssetAllocationBurnToSyscoinStandard TokenStandardName = "SPTAssetAllocationBurnToSyscoin" + SPTAssetAllocationBurnToNEVMStandard TokenStandardName = "SPTAssetAllocationBurnToNEVM" + + // SYSCOIN: historical names used by the Syscoin SPT parser and API. + SPTNoneType TokenType = "Syscoin" + SPTTokenType TokenType = SPTTokenStandard + SPTUnknownType TokenType = "SPTUnknown" + SPTAssetAllocationMintType TokenType = SPTAssetAllocationMintStandard + SPTAssetAllocationSendType TokenType = SPTAssetAllocationSendStandard + SPTAssetSyscoinBurnToAllocationType TokenType = SPTAssetSyscoinBurnToAllocationStandard + SPTAssetAllocationBurnToSyscoinType TokenType = SPTAssetAllocationBurnToSyscoinStandard + SPTAssetAllocationBurnToNEVMType TokenType = SPTAssetAllocationBurnToNEVMStandard +) + +// TokenTransfers is array of TokenTransfer +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].Standard < a[j].Standard } // Block is block header and list of transactions type Block struct { BlockHeader - Txs []Tx `json:"tx"` + 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"` - 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 { @@ -206,6 +270,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:" { @@ -214,167 +285,12 @@ func AddressDescriptorFromString(s string) (AddressDescriptor, error) { return nil, errors.New("invalid address descriptor") } -// EthereumType specific - -// Erc20Contract contains info about ERC20 contract -type Erc20Contract struct { - Contract string `json:"contract"` - Name string `json:"name"` - Symbol string `json:"symbol"` - Decimals int `json:"decimals"` -} - -// Erc20Transfer contains a single ERC20 token transfer -type Erc20Transfer struct { - Contract string - From string - To string - Tokens big.Int -} - // MempoolTxidEntry contains mempool txid with first seen time type MempoolTxidEntry struct { - Txid string - Time uint32 -} - -// Utxo holds information about unspent transaction output -type Utxo struct { - BtxID []byte - Vout int32 - Height uint32 - ValueSat big.Int - AssetInfo *AssetInfo `json:"assetInfo,omitempty"` -} -// holds balance information for an asset indexed by a uint32 asset guid -type AssetBalance struct { - SentSat *big.Int - BalanceSat *big.Int - Transfers uint32 -} - -// AddrBalance stores number of transactions and balances of an address -type AddrBalance struct { - Txs uint32 - SentSat big.Int - BalanceSat big.Int - Utxos []Utxo - UtxosMap map[string]int - AssetBalances map[uint64]*AssetBalance + 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."` } - -// ReceivedSat computes received amount from total balance and sent amount -func (ab *AddrBalance) ReceivedSat() *big.Int { - var r big.Int - r.Add(&ab.BalanceSat, &ab.SentSat) - return &r -} - -// calc received based on balance, sent passed in -func ReceivedSatFromBalances(balance *big.Int, sent *big.Int) *big.Int { - var r big.Int - r.Add(balance,sent) - return &r -} - - -// addUtxo -func (ab *AddrBalance) AddUtxo(u *Utxo) { - ab.Utxos = append(ab.Utxos, *u) - ab.manageUtxoMap(u) -} - -func (ab *AddrBalance) manageUtxoMap(u *Utxo) { - l := len(ab.Utxos) - if l >= 16 { - if len(ab.UtxosMap) == 0 { - ab.UtxosMap = make(map[string]int, 32) - for i := 0; i < l; i++ { - s := string(ab.Utxos[i].BtxID) - if _, e := ab.UtxosMap[s]; !e { - ab.UtxosMap[s] = i - } - } - } else { - s := string(u.BtxID) - if _, e := ab.UtxosMap[s]; !e { - ab.UtxosMap[s] = l - 1 - } - } - } -} - -// on disconnect, the added utxos must be inserted in the right position so that UtxosMap index works -func (ab *AddrBalance) AddUtxoInDisconnect(u *Utxo) { - insert := -1 - if len(ab.UtxosMap) > 0 { - if i, e := ab.UtxosMap[string(u.BtxID)]; e { - insert = i - } - } else { - for i := range ab.Utxos { - utxo := &ab.Utxos[i] - if *(*int)(unsafe.Pointer(&utxo.BtxID[0])) == *(*int)(unsafe.Pointer(&u.BtxID[0])) && bytes.Equal(utxo.BtxID, u.BtxID) { - insert = i - break - } - } - } - if insert > -1 { - // check if it is necessary to insert the utxo into the array - for i := insert; i < len(ab.Utxos); i++ { - utxo := &ab.Utxos[i] - // either the vout is greater than the inserted vout or it is a different tx - if utxo.Vout > u.Vout || *(*int)(unsafe.Pointer(&utxo.BtxID[0])) != *(*int)(unsafe.Pointer(&u.BtxID[0])) || !bytes.Equal(utxo.BtxID, u.BtxID) { - // found the right place, insert the utxo - ab.Utxos = append(ab.Utxos, *u) - copy(ab.Utxos[i+1:], ab.Utxos[i:]) - ab.Utxos[i] = *u - // reset UtxosMap after insert, the index will have to be rebuilt if needed - ab.UtxosMap = nil - return - } - } - } - ab.Utxos = append(ab.Utxos, *u) - ab.manageUtxoMap(u) -} - -// MarkUtxoAsSpent finds outpoint btxID:vout in utxos and marks it as spent -// for small number of utxos the linear search is done, for larger number there is a hashmap index -// it is much faster than removing the utxo from the slice as it would cause in memory reallocations -func (ab *AddrBalance) MarkUtxoAsSpent(btxID []byte, vout int32) { - if len(ab.UtxosMap) == 0 { - for i := range ab.Utxos { - utxo := &ab.Utxos[i] - if utxo.Vout == vout && *(*int)(unsafe.Pointer(&utxo.BtxID[0])) == *(*int)(unsafe.Pointer(&btxID[0])) && bytes.Equal(utxo.BtxID, btxID) { - // mark utxo as spent by setting vout=-1 - utxo.Vout = -1 - return - } - } - } else { - if i, e := ab.UtxosMap[string(btxID)]; e { - l := len(ab.Utxos) - for ; i < l; i++ { - utxo := &ab.Utxos[i] - if utxo.Vout == vout { - if bytes.Equal(utxo.BtxID, btxID) { - // mark utxo as spent by setting vout=-1 - utxo.Vout = -1 - return - } - break - } - } - } - } - glog.Errorf("Utxo %s:%d not found, UtxosMap size %d", hex.EncodeToString(btxID), vout, len(ab.UtxosMap)) -} - -// AddressBalanceDetail specifies what data are returned by GetAddressBalance -type AddressBalanceDetail int // ScriptType - type of output script parsed from xpub (descriptor) type ScriptType int @@ -387,237 +303,127 @@ const ( P2TR ) +// MaxXpubChangeIndexes limits how many change branches one xpub descriptor can +// expand during account scans. +const MaxXpubChangeIndexes = 10 + // 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) - -// OnNewTxAddrFunc is used to send notification about a new transaction/address -type OnNewTxAddrFunc func(tx *Tx, desc AddressDescriptor) - -// OnNewTxFunc is used to send notification about a new transaction/address -type OnNewTxFunc func(tx *MempoolTx) +// 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."` +} -// AddrDescForOutpointFunc returns address descriptor and value for given outpoint or nil if outpoint not found -type AddrDescForOutpointFunc func(outpoint Outpoint) (AddressDescriptor, *big.Int) +// 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"` +} +// SYSCOIN: AssetsMask filters base coin and SPT transaction classes. type AssetsMask uint32 -// Addresses index -type TxIndexes struct { - BtxID []byte - Indexes []int32 - Type AssetsMask - Assets []uint64 +const ( + AllMask AssetsMask = 0 + BaseCoinMask AssetsMask = 1 + AssetAllocationSendMask AssetsMask = 2 + AssetSyscoinBurnToAllocationMask AssetsMask = 4 + AssetAllocationBurnToSyscoinMask AssetsMask = 8 + AssetAllocationBurnToNEVMMask AssetsMask = 16 + AssetAllocationMintMask AssetsMask = 32 + AssetMask AssetsMask = AssetSyscoinBurnToAllocationMask | AssetAllocationBurnToSyscoinMask | AssetAllocationBurnToNEVMMask | AssetAllocationMintMask | AssetAllocationSendMask +) +// SYSCOIN: AssetAllocation and Asset wrap the Syscoin wire SPT payloads used by +// the parser and Syscoin-specific RocksDB column families. +type AssetAllocation struct { + AssetObj syscoinwire.AssetAllocationType } -// AddressesMap is a map of addresses in a block -// each address contains a slice of transactions with indexes where the address appears -// slice is used instead of map so that order is defined and also search in case of few items -type AddressesMap map[string][]TxIndexes - - -// TxInput holds input data of the transaction in TxAddresses -type TxInput struct { - AddrDesc AddressDescriptor - ValueSat big.Int - AssetInfo *AssetInfo `json:"assetInfo,omitempty"` +type Asset struct { + Transactions uint32 + AssetObj syscoinwire.AssetType + MetaData []byte } -// BlockInfo holds information about blocks kept in column height -type DbBlockInfo struct { - Hash string - Time int64 - Txs uint32 - Size uint32 - Height uint32 // Height is not packed! +type AssetBalance struct { + Transfers uint32 + BalanceSat *big.Int + SentSat *big.Int } -// TxOutput holds output data of the transaction in TxAddresses -type TxOutput struct { - AddrDesc AddressDescriptor - Spent bool - ValueSat big.Int - AssetInfo *AssetInfo `json:"assetInfo,omitempty"` -} - -// Addresses converts AddressDescriptor of the input to array of strings -func (ti *TxInput) Addresses(p BlockChainParser) ([]string, bool, error) { - return p.GetAddressesFromAddrDesc(ti.AddrDesc) -} - -// Addresses converts AddressDescriptor of the output to array of strings -func (to *TxOutput) Addresses(p BlockChainParser) ([]string, bool, error) { - return p.GetAddressesFromAddrDesc(to.AddrDesc) -} - -// TokenType specifies type of token -type TokenType string - -// ERC20TokenType is Ethereum ERC20 token -const ERC20TokenType TokenType = "ERC20" - -// XPUBAddressTokenType is address derived from xpub -const XPUBAddressTokenType TokenType = "XPUBAddress" - -// Syscoin SPT transaction -const SPTNoneType TokenType = "Syscoin" -const SPTTokenType TokenType = "SPTAllocated" -const SPTUnknownType TokenType = "SPTUnknown" -const SPTAssetAllocationMintType TokenType = "SPTAssetAllocationMint" -const SPTAssetAllocationSendType TokenType = "SPTAssetAllocationSend" -const SPTAssetSyscoinBurnToAllocationType TokenType = "SPTSyscoinBurnToAssetAllocation" -const SPTAssetAllocationBurnToSyscoinType TokenType = "SPTAssetAllocationBurnToSyscoin" -const SPTAssetAllocationBurnToNEVMType TokenType = "SPTAssetAllocationBurnToNEVM" - -const AllMask AssetsMask = 0 -const BaseCoinMask AssetsMask = 1 -const AssetAllocationSendMask AssetsMask = 2 -const AssetSyscoinBurnToAllocationMask AssetsMask = 4 -const AssetAllocationBurnToSyscoinMask AssetsMask = 8 -const AssetAllocationBurnToNEVMMask AssetsMask = 16 -const AssetAllocationMintMask AssetsMask = 32 -const AssetMask AssetsMask = AssetSyscoinBurnToAllocationMask | AssetAllocationBurnToSyscoinMask | AssetAllocationBurnToNEVMMask | AssetAllocationMintMask | AssetAllocationSendMask -// Amount is datatype holding amounts -type Amount big.Int -// MarshalJSON Amount serialization -func (a *Amount) MarshalJSON() (out []byte, err error) { - if a == nil { - return []byte(`"0"`), nil - } - return []byte(`"` + (*big.Int)(a).String() + `"`), nil +type TxAssetIndex struct { + Type AssetsMask + BtxID []byte } -func (a *Amount) String() string { - if a == nil { - return "" - } - return (*big.Int)(a).String() +type TxAsset struct { + Height uint32 + Txs []*TxAssetIndex } -// DecimalString returns amount with decimal point placed according to parameter d -func (a *Amount) DecimalString(d int) string { - return AmountToDecimalString((*big.Int)(a), d) -} +type TxAssetMap map[string]*TxAsset -// AsBigInt returns big.Int type for the Amount (empty if Amount is nil) -func (a *Amount) AsBigInt() big.Int { - if a == nil { - return *new(big.Int) - } - return big.Int(*a) +type TxAssetAddressIndex struct { + AddrDesc AddressDescriptor + BtxID []byte } -// 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. -func (a *Amount) AsInt64() int64 { - if a == nil { - return 0 - } - return (*big.Int)(a).Int64() +type TxAssetAddress struct { + Txs []*TxAssetAddressIndex } +type TxAssetAddressMap map[uint64]*TxAssetAddress -// encapuslates Syscoin SPT wire types -type AssetAllocation struct { - AssetObj wire.AssetAllocationType -} -type Asset struct { - Transactions uint32 - AssetObj wire.AssetType - MetaData []byte -} -// Assets is array of Asset -type Assets []Asset - -func (a Assets) Len() int { return len(a) } -func (a Assets) Swap(i, j int) { - a[i], a[j] = a[j], a[i] -} -func (a Assets) Less(i, j int) bool { - return string(a[i].AssetObj.Symbol) < string(a[j].AssetObj.Symbol) -} - -// Token contains info about tokens held by an address -type Token struct { - Type TokenType `json:"type"` - Name string `json:"name"` - Path string `json:"path,omitempty"` - Contract string `json:"contract,omitempty"` - AssetGuid string `json:"assetGuid,omitempty"` - Transfers uint32 `json:"transfers"` - UnconfirmedTransfers int `json:"unconfirmedTransfers,omitempty"` - Symbol string `json:"symbol,omitempty"` - Decimals int `json:"decimals"` - BalanceSat *Amount `json:"balance,omitempty"` - UnconfirmedBalanceSat *Amount `json:"unconfirmedBalance,omitempty"` - TotalReceivedSat *Amount `json:"totalReceived,omitempty"` - TotalSentSat *Amount `json:"totalSent,omitempty"` - ContractIndex string `json:"-"` - AddrStr string `json:"addrStr,omitempty"` -} -type Tokens []*Token -func (t Tokens) Len() int { return len(t) } -func (t Tokens) Swap(i, j int) { - if t[i] != nil && t[j] != nil && t[i].Type != XPUBAddressTokenType && t[j].Type != XPUBAddressTokenType { - t[i], t[j] = t[j], t[i] - } -} -func (t Tokens) Less(i, j int) bool { - if t[i] == nil || t[j] == nil || t[i].Type == XPUBAddressTokenType || t[j].Type == XPUBAddressTokenType { - return false - } - return t[i].Contract < t[j].Contract -} - -// TokenTransferSummary contains info about a token transfer done in a transaction -type TokenTransferSummary struct { - From string `json:"from"` - To string `json:"to"` - Token string `json:"token"` - Name string `json:"name"` - Symbol string `json:"symbol"` - Decimals int `json:"decimals"` - Value *Amount `json:"valueOut"` - Fee *Amount `json:"fee,omitempty"` +// SYSCOIN: legacy parser-side packing structs kept for the Syscoin parser's +// SPT serializers. Upstream DB code owns its own internal tx-address structs. +type TxInput struct { + AddrDesc AddressDescriptor + ValueSat big.Int + AssetInfo *AssetInfo } -// used to store all txids related to an asset for asset history -type TxAssetIndex struct { - Type AssetsMask - BtxID []byte +type TxOutput struct { + AddrDesc AddressDescriptor + Spent bool + ValueSat big.Int + AssetInfo *AssetInfo } -type TxAsset struct { +type Utxo struct { + BtxID []byte + Vout int32 Height uint32 - Txs []*TxAssetIndex + ValueSat big.Int + AssetInfo *AssetInfo } -type TxAssetMap map[string]*TxAsset -// used to store all unique txid/address tuples related to an asset -type TxAssetAddressIndex struct { - AddrDesc AddressDescriptor - BtxID []byte +type AddrBalance struct { + Txs uint32 + SentSat big.Int + BalanceSat big.Int + Utxos []Utxo + AssetBalances map[uint64]*AssetBalance } -type TxAssetAddress struct { - Txs []*TxAssetAddressIndex + +func (ab *AddrBalance) AddUtxo(u *Utxo) { + ab.Utxos = append(ab.Utxos, *u) } -type TxAssetAddressMap map[uint64]*TxAssetAddress -type AssetsMap map[uint64]int64 -// TxAddresses stores transaction inputs and outputs with amounts + type TxAddresses struct { Version int32 Height uint32 @@ -626,38 +432,50 @@ type TxAddresses struct { Memo []byte } -type DbOutpoint struct { - BtxID []byte - Index int32 -} - -type BlockTxs struct { - BtxID []byte - Inputs []DbOutpoint -} +// SYSCOIN: legacy constants/types used by parser-side SPT serializers. +type AddressBalanceDetail int const ( - // AddressBalanceDetailNoUTXO returns address balance without utxos - AddressBalanceDetailNoUTXO = 0 - // AddressBalanceDetailUTXO returns address balance with utxos - AddressBalanceDetailUTXO = 1 - // AddressBalanceDetailUTXOIndexed returns address balance with utxos and index for updates, used only internally - AddressBalanceDetailUTXOIndexed = 2 + AddressBalanceDetailNoUTXO = AddressBalanceDetail(iota) + AddressBalanceDetailUTXO + AddressBalanceDetailUTXOIndexed ) -// SendRawTransactionParams carries optional arguments for Syscoin Core sendrawtransaction -// (maxfeerate, maxburnamount). Used with SendRawTransactionOpts. -// The implementation (see syscoinrpc.go) takes care of the proper optional argument settings. +type TxIndexes struct { + Type AssetsMask + BtxID []byte + Indexes []int32 + Assets []uint64 +} + +// 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) + +// AddrDescForOutpointFunc returns address descriptor, value, and optional +// asset metadata for given outpoint or nil if outpoint not found. +type AddrDescForOutpointFunc func(outpoint Outpoint) (AddressDescriptor, *big.Int, *AssetInfo) + +// MempoolBatcher allows batch fetching of mempool transactions when supported. +type MempoolBatcher interface { + GetRawTransactionsForMempoolBatch(txids []string) (map[string]*Tx, error) +} + +// SYSCOIN: optional Bitcoin-like sendrawtransaction parameters used by Syscoin +// Core for governance collateral burns. Other Bitcoin-like coins ignore this +// opt-in interface and keep the upstream raw-hex broadcast contract. type SendRawTransactionParams struct { - Hex string - MaxFeeRate *string - MaxBurnAmount *string + Hex string + MaxFeeRate *string + MaxBurnAmount *string + DisableAlternativeRPC bool } -// SendRawTransactionOpts is implemented by chains whose node supports -// sendrawtransaction with maxfeerate / maxburnamount (Syscoin). +// SYSCOIN: implemented by chains that accept extra sendrawtransaction params. type SendRawTransactionOpts interface { - SendRawTransactionWithOpts(p SendRawTransactionParams) (string, error) + SendRawTransactionWithOpts(SendRawTransactionParams) (string, error) } // BlockChain defines common interface to block chain daemon @@ -668,7 +486,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 @@ -689,22 +507,29 @@ 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) + GetSPVProof(hash string) (json.RawMessage, error) // SYSCOIN + GetContractInfo(contractDesc AddressDescriptor) (*ContractInfo, error) // parser GetChainParser() BlockChainParser // EthereumType specific EthereumTypeGetBalance(addrDesc AddressDescriptor) (*big.Int, error) - EthereumTypeGetNonce(addrDesc AddressDescriptor) (uint64, error) + EthereumTypeGetNonces(addrDesc AddressDescriptor, withConfirmed bool) (pending uint64, confirmed uint64, confirmedOK bool, err error) EthereumTypeEstimateGas(params map[string]interface{}) (uint64, error) - EthereumTypeGetErc20ContractInfo(contractDesc AddressDescriptor) (*Erc20Contract, error) + EthereumTypeGetEip1559Fees() (*Eip1559Fees, error) EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc AddressDescriptor) (*big.Int, error) - GetChainTips() (string, error) - GetSPVProof(hash string) (string, error) - FetchNEVMAssetDetails(assetGuid uint64) (*Asset, error) - GetContractExplorerBaseURL() string + 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) } // BlockChainParser defines common interface to parsing and conversions of block chain data @@ -716,25 +541,25 @@ type BlockChainParser interface { KeepBlockAddresses() int // AmountDecimals returns number of decimal places in coin amounts AmountDecimals() int + // UseAddressAliases returns true if address aliases are enabled + UseAddressAliases() bool // MinimumCoinbaseConfirmations returns minimum number of confirmations a coinbase transaction must have before it can be spent MinimumCoinbaseConfirmations() int + // SupportsVSize returns true if vsize of a transaction should be computed and returned by API + SupportsVSize() bool // AmountToDecimalString converts amount in big.Int to string with decimal point in the correct place AmountToDecimalString(a *big.Int) string // AmountToBigInt converts amount in common.JSONNumber (string) to big.Int // it uses string operations to avoid problems with rounding AmountToBigInt(n common.JSONNumber) (big.Int, error) - // get max script length, in bitcoin base derivatives its 1024 - // but for example in syscoin this is going to be 8000 for max opreturn output script for syscoin coloured tx - GetMaxAddrLength() int // address descriptor conversions GetAddrDescFromVout(output *Vout) (AddressDescriptor, error) GetAddrDescFromAddress(address string) (AddressDescriptor, error) GetAddressesFromAddrDesc(addrDesc AddressDescriptor) ([]string, bool, error) GetScriptFromAddrDesc(addrDesc AddressDescriptor) ([]byte, error) IsAddrDescIndexable(addrDesc AddressDescriptor) bool - // parsing/packing/unpacking specific to chain + // transactions PackedTxidLen() int - PackedTxIndexLen() int PackTxid(txid string) ([]byte, error) UnpackTxid(buf []byte) (string, error) ParseTx(b []byte) (*Tx, error) @@ -742,42 +567,6 @@ type BlockChainParser interface { PackTx(tx *Tx, height uint32, blockTime int64) ([]byte, error) UnpackTx(buf []byte) (*Tx, uint32, error) GetAddrDescForUnknownInput(tx *Tx, input int) AddressDescriptor - PackAddrBalance(ab *AddrBalance, buf, varBuf []byte) []byte - UnpackAddrBalance(buf []byte, txidUnpackedLen int, detail AddressBalanceDetail) (*AddrBalance, error) - PackAddressKey(addrDesc AddressDescriptor, height uint32) []byte - UnpackAddressKey(key []byte) ([]byte, uint32, error) - PackTxAddresses(ta *TxAddresses, buf []byte, varBuf []byte) []byte - AppendTxInput(txi *TxInput, buf []byte, varBuf []byte) []byte - AppendTxOutput(txo *TxOutput, buf []byte, varBuf []byte) []byte - UnpackTxAddresses(buf []byte) (*TxAddresses, error) - UnpackTxInput(ti *TxInput, buf []byte) int - UnpackTxOutput(to *TxOutput, buf []byte) int - PackTxIndexes(txi []TxIndexes) []byte - UnpackTxIndexes(txindexes *[]int32, buf *[]byte) error - UnpackTxIndexAssets(assets *[]uint64, buf *[]byte) uint - PackOutpoints(outpoints []DbOutpoint) []byte - UnpackNOutpoints(buf []byte) ([]DbOutpoint, int, error) - PackBlockInfo(block *DbBlockInfo) ([]byte, error) - UnpackBlockInfo(buf []byte) (*DbBlockInfo, error) - // packing/unpacking generic to all chain (expect this to be in baseparser) - PackUint(i uint32) []byte - UnpackUint(buf []byte) uint32 - PackUint64(i uint64) []byte - UnpackUint64(buf []byte) uint64 - PackVarint32(i int32, buf []byte) int - PackVarint(i int, buf []byte) int - PackVaruint(i uint, buf []byte) int - PackVaruint64(i uint64, buf []byte) int - UnpackVarint32(buf []byte) (int32, int) - UnpackVarint(buf []byte) (int, int) - UnpackVaruint(buf []byte) (uint, int) - UnpackVaruint64(buf []byte) (uint64, int) - PackBigint(bi *big.Int, buf []byte) int - UnpackBigint(buf []byte) (big.Int, int) - MaxPackedBigintBytes() int - UnpackVarBytes(buf []byte) ([]byte, int) - PackVarBytes(bufValue []byte) []byte - // blocks PackBlockHash(hash string) ([]byte, error) UnpackBlockHash(buf []byte) (string, error) @@ -788,28 +577,13 @@ type BlockChainParser interface { DeriveAddressDescriptors(descriptor *XpubDescriptor, change uint32, indexes []uint32) ([]AddressDescriptor, error) DeriveAddressDescriptorsFromTo(descriptor *XpubDescriptor, change uint32, fromIndex uint32, toIndex uint32) ([]AddressDescriptor, error) // EthereumType specific - EthereumTypeGetErc20FromTx(tx *Tx) ([]Erc20Transfer, error) - // SyscoinType specific - IsSyscoinTx(nVersion int32, nHeight uint32) bool - IsSyscoinMintTx(nVersion int32) bool - IsAssetAllocationTx(nVersion int32) bool - TryGetOPReturn(script []byte) []byte - GetAssetsMaskFromVersion(nVersion int32) AssetsMask - GetAssetTypeFromVersion(nVersion int32) *TokenType - PackAssetKey(assetGuid uint64, height uint32) []byte - UnpackAssetKey(key []byte) (uint64, uint32) - PackAssetTxIndex(txAsset *TxAsset) []byte - UnpackAssetTxIndex(buf []byte) []*TxAssetIndex - PackAsset(asset *Asset) ([]byte, error) - UnpackAsset(buf []byte) (*Asset, error) - GetAssetAllocationFromData(sptData []byte, txVersion int32) (*AssetAllocation, []byte, error) - GetAssetAllocationFromDesc(addrDesc *AddressDescriptor, txVersion int32) (*AssetAllocation, []byte, error) - GetAllocationFromTx(tx *Tx) (*AssetAllocation, []byte, error) - LoadAssets(tx *Tx) error - AppendAssetInfo(assetInfo *AssetInfo, buf []byte, varBuf []byte) []byte - UnpackAssetInfo(assetInfo *AssetInfo, buf []byte) int - UnpackTxIndexType(buf []byte) (AssetsMask, int) - WitnessPubKeyHashFromKeyID(keyId []byte) (string, error) + 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 } // Mempool defines common interface to mempool @@ -818,6 +592,27 @@ type Mempool interface { GetTransactions(address string) ([]Outpoint, error) GetAddrDescTransactions(addrDesc AddressDescriptor) ([]Outpoint, error) GetAllEntries() MempoolTxidEntries + GetTxAssets(assetGuid uint64) MempoolTxidEntries // SYSCOIN GetTransactionTime(txid string) uint32 - GetTxAssets(assetGuid uint64) MempoolTxidEntries -} \ No newline at end of file + GetTxidFilterEntries(filterScripts string, fromTimestamp uint32) (MempoolTxidFilterEntries, error) +} + +// MissingBlockRetry is the JSON wire shape for per-chain overrides of the +// sync-worker missing-block retry policy. Each field is optional; zero / missing +// values fall back to the db package's built-in defaults. Operators set this +// under `additional_params.missingBlockRetry` in `configs/coins/*.json`. +type MissingBlockRetry struct { + // RetryDelayMs is the sleep between successive GetBlock attempts for the same + // missing block in the parallel worker path. The sequential tip path applies + // an additional internal cap of 250 ms regardless of this value. + RetryDelayMs int `json:"retryDelayMs,omitempty"` + // RecheckThreshold is the number of consecutive retryable errors in the + // parallel worker before probing the chain via shouldRestartSyncOnMissingBlock. + RecheckThreshold int `json:"recheckThreshold,omitempty"` + // TipRecheckThreshold is the equivalent threshold once the hash queue is + // closed (we are at the tail of a range) or for the sequential tip path. + TipRecheckThreshold int `json:"tipRecheckThreshold,omitempty"` + // MaxStallMs is the wall-clock budget per stuck block before the retry loop + // yields errResync to the outer machinery. + MaxStallMs int `json:"maxStallMs,omitempty"` +} diff --git a/bchain/types_chainextradata.go b/bchain/types_chainextradata.go new file mode 100644 index 0000000000..e8f69ea1bd --- /dev/null +++ b/bchain/types_chainextradata.go @@ -0,0 +1,9 @@ +package bchain + +// ChainExtraPayloadType identifies the normalized chainExtraData payload shape. +type ChainExtraPayloadType string + +const ( + ChainExtraPayloadTypeUnknown ChainExtraPayloadType = "" + ChainExtraPayloadTypeTron ChainExtraPayloadType = "tron" +) diff --git a/bchain/types_ethereum_type.go b/bchain/types_ethereum_type.go new file mode 100644 index 0000000000..ceec2121f8 --- /dev/null +++ b/bchain/types_ethereum_type.go @@ -0,0 +1,270 @@ +package bchain + +import ( + "encoding/json" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" +) + +// 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" 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 `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 `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" 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" 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 +type EthereumInternalTransactionType int + +// EthereumInternalTransactionType enumeration +const ( + CALL = EthereumInternalTransactionType(iota) + CREATE + SELFDESTRUCT +) + +// EthereumInternalData contains internal transfers +type EthereumInternalData struct { + 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 { + // 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 +} + +// 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 ( + ERC20TokenStandard TokenStandardName = "ERC20" + ERC771TokenStandard TokenStandardName = "ERC721" + ERC1155TokenStandard TokenStandardName = "ERC1155" +) + +// 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 `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 { + 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" 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"` + // S string `json:"s"` +} + +// RpcLog is returned by eth_getLogs +type RpcLog struct { + 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."` +} + +// RpcReceipt is returned by eth_getTransactionReceipt +type RpcReceipt struct { + 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" 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 `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 `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/bchain/types_tron_chainextradata.go b/bchain/types_tron_chainextradata.go new file mode 100644 index 0000000000..f79348e8ac --- /dev/null +++ b/bchain/types_tron_chainextradata.go @@ -0,0 +1,71 @@ +package bchain + +// 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"` + Note string `json:"note,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"` +} + +// TronUnstakingBatch describes one pending Tron unstaking batch (Stake 2.0). +type TronUnstakingBatch struct { + Amount string `json:"amount"` + ExpireTime int64 `json:"expireTime"` +} + +// TronVote describes one current vote allocation to a Tron Super Representative. +type TronVote struct { + Address string `json:"address"` + VoteCount string `json:"voteCount"` +} + +// TronStakingInfo contains normalized Tron staking and governance account metadata. +type TronStakingInfo struct { + StakedBalance string `json:"stakedBalance"` + StakedBalanceEnergy string `json:"stakedBalanceEnergy"` + StakedBalanceBandwidth string `json:"stakedBalanceBandwidth"` + UnstakingBatches []TronUnstakingBatch `json:"unstakingBatches"` + TotalVotingPower string `json:"totalVotingPower"` + AvailableVotingPower string `json:"availableVotingPower"` + Votes []TronVote `json:"votes"` + UnclaimedReward string `json:"unclaimedReward"` + DelegatedBalanceEnergy string `json:"delegatedBalanceEnergy"` + DelegatedBalanceBandwidth string `json:"delegatedBalanceBandwidth"` +} + +// 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"` + TotalEnergyLimit int64 `json:"totalEnergyLimit"` + TotalEnergyWeight int64 `json:"totalEnergyWeight"` + TotalBandwidthLimit int64 `json:"totalBandwidthLimit"` + TotalBandwidthWeight int64 `json:"totalBandwidthWeight"` + StakingInfo *TronStakingInfo `json:"stakingInfo,omitempty"` +} diff --git a/blockbook-api.ts b/blockbook-api.ts new file mode 100644 index 0000000000..84287c7c4a --- /dev/null +++ b/blockbook-api.ts @@ -0,0 +1,912 @@ +/* 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; + note?: 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 TronVote { + address: string; + voteCount: string; +} +export interface TronUnstakingBatch { + amount: string; + expireTime: number; +} +export interface TronStakingInfo { + stakedBalance: string; + stakedBalanceEnergy: string; + stakedBalanceBandwidth: string; + unstakingBatches: TronUnstakingBatch[]; + totalVotingPower: string; + availableVotingPower: string; + votes: TronVote[]; + unclaimedReward: string; + delegatedBalanceEnergy: string; + delegatedBalanceBandwidth: string; +} +export interface TronAccountExtraData { + availableStakedBandwidth: number; + totalStakedBandwidth: number; + availableFreeBandwidth: number; + totalFreeBandwidth: number; + availableEnergy: number; + totalEnergy: number; + totalEnergyLimit: number; + totalEnergyWeight: number; + totalBandwidthLimit: number; + totalBandwidthWeight: number; + stakingInfo?: TronStakingInfo; +} +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. Always present; defaults to the coin convention (18 for ERC-20) when the contract value is unavailable. */ + 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. Always present; defaults to the coin convention (18 for ERC-20) when the contract value is unavailable. */ + 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; + /** Protocol identifiers the contract participates in (e.g., "erc4626"); for fresh per-vault data, use getContractInfo. */ + protocols?: string[]; +} +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. Omitted for AccountDetailsBasic, where mempool transactions are not aggregated. */ + 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 (pending) transaction nonce for Ethereum-like addresses, including mempool transactions. This is the next nonce the account will use. */ + nonce?: string; + /** Confirmed transaction nonce for Ethereum-like addresses, reflecting only mined transactions (eth_getTransactionCount at the latest block). Equals nonce when the account has no pending transactions. */ + confirmedNonce?: 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; + /** If true, additionally return the confirmed nonce for Ethereum-like addresses (extra backend call). */ + confirmedNonce?: boolean; +} +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 99bacd956d..0b56ce016f 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,24 +10,26 @@ import ( "os" "os/signal" "runtime/debug" + "strconv" "strings" - "sync/atomic" + "sync" "syscall" "time" "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/api" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins" - "github.com/syscoin/blockbook/common" - "github.com/syscoin/blockbook/db" - "github.com/syscoin/blockbook/fiat" - "github.com/syscoin/blockbook/server" + "github.com/trezor/blockbook/api" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" + "github.com/trezor/blockbook/fiat" + "github.com/trezor/blockbook/fourbyte" + "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 @@ -42,7 +42,7 @@ const exitCodeOK = 0 const exitCodeFatal = 255 var ( - blockchain = flag.String("blockchaincfg", "", "path to blockchain RPC service configuration json file") + configFile = flag.String("blockchaincfg", "", "path to blockchain RPC service configuration json file") dbPath = flag.String("datadir", "./data", "path to database directory") dbCache = flag.Int("dbcache", 1<<29, "size of the rocksdb cache") @@ -82,13 +82,22 @@ 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 ( - chanSyncIndex = make(chan struct{}) - chanSyncMempool = make(chan struct{}) + // Buffer 1 + non-blocking sends in pushSynchronizationHandler decouple the + // WebSocket/ZMQ source goroutines from sync progress. If sync is stuck inside + // TickAndDebounce's f(), additional notifications are dropped here rather + // than blocking the source; TickAndDebounce's tick-period backstop still fires. + chanSyncIndex = make(chan struct{}, 1) + chanSyncMempool = make(chan struct{}, 1) chanStoreInternalState = make(chan struct{}) chanSyncIndexDone = make(chan struct{}) chanSyncMempoolDone = make(chan struct{}) @@ -100,12 +109,11 @@ 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 - inShutdown int32 ) func init() { @@ -133,7 +141,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) @@ -151,38 +176,50 @@ func mainWithExitCode() int { return exitCodeOK } - if *blockchain == "" { - glog.Error("Missing blockchaincfg configuration parameter") - return exitCodeFatal - } - - coin, coinShortcut, coinLabel, err := coins.GetCoinNameFromConfig(*blockchain) + config, err := common.GetConfig(*configFile) if err != nil { glog.Error("config: ", err) return exitCodeFatal } - // gspt.SetProcTitle("blockbook-" + normalizeName(coin)) - - 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, *blockchain, 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, chain) + // Chains that expose a configured average block time (currently EVM coins via + // EthereumRPC.AverageBlockTimeDuration) publish it as a static gauge so + // alerts can normalize the tip-age metric across coins with different cadences. + if provider, ok := chain.(interface { + AverageBlockTimeDuration() (time.Duration, error) + }); ok { + if d, err := provider.AverageBlockTimeDuration(); err == nil && d > 0 { + metrics.AverageBlockTimeSeconds.Set(d.Seconds()) + } + } + + index, err = db.NewRocksDB(*dbPath, *dbCache, *dbMaxOpenFiles, chain.GetChainParser(), metrics, *extendedIndex) if err != nil { glog.Error("rocksDB: ", err) return exitCodeFatal } - defer index.Close() + // SYSCOIN: allows the asset index to lazily enrich missing metadata from NEVM. + index.SetBlockChain(chain) + 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 @@ -197,6 +234,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) @@ -236,7 +284,24 @@ func mainWithExitCode() int { return exitCodeOK } - syncWorker, err = db.NewSyncWorker(index, chain, *syncWorkers, *syncChunk, *blockFrom, *dryRun, chanOsSignal, metrics, internalState) + // Per-chain missing-block retry override, if any. Coin RPCs that opt in + // expose MissingBlockRetryOverride(); chains without the method keep defaults. + var syncCfg *db.SyncWorkerConfig + if provider, ok := chain.(interface { + MissingBlockRetryOverride() *bchain.MissingBlockRetry + }); ok { + if override := provider.MissingBlockRetryOverride(); override != nil { + syncCfg = &db.SyncWorkerConfig{ + MissingBlockRetry: db.ApplyMissingBlockRetryOverride(override), + } + glog.Infof("sync: missingBlockRetry override applied: retryDelay=%s recheckThreshold=%d tipRecheckThreshold=%d maxStall=%s", + syncCfg.MissingBlockRetry.RetryDelay, + syncCfg.MissingBlockRetry.RecheckThreshold, + syncCfg.MissingBlockRetry.TipRecheckThreshold, + syncCfg.MissingBlockRetry.MaxStallDuration) + } + } + syncWorker, err = db.NewSyncWorkerWithConfig(index, chain, *syncWorkers, *syncChunk, *blockFrom, *dryRun, chanOsSignal, metrics, internalState, syncCfg) if err != nil { glog.Errorf("NewSyncWorker %v", err) return exitCodeFatal @@ -263,6 +328,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) @@ -301,7 +371,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 @@ -321,7 +391,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() @@ -335,7 +404,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 @@ -347,28 +416,29 @@ func mainWithExitCode() int { if internalServer != nil || publicServer != nil || chain != nil { // start fiat rates downloader only if not shutting down immediately - initFiatRatesDownloader(index, *blockchain) - 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 } -func getBlockChainWithRetry(coin string, configfile string, pushHandler func(bchain.NotificationType), metrics *common.Metrics, seconds int) (bchain.BlockChain, bchain.Mempool, error) { +func getBlockChainWithRetry(coin string, configFile string, pushHandler func(bchain.NotificationType), metrics *common.Metrics, seconds int) (bchain.BlockChain, bchain.Mempool, error) { var chain bchain.BlockChain var mempool bchain.Mempool var err error timer := time.NewTimer(time.Second) for i := 0; ; i++ { - if chain, mempool, err = coins.NewBlockChain(coin, configfile, pushHandler, metrics); err != nil { + if chain, mempool, err = coins.NewBlockChain(coin, configFile, pushHandler, metrics); err != nil { if i < seconds { glog.Error("rpc: ", err, " Retrying...") select { @@ -387,7 +457,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 } @@ -407,7 +477,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 } @@ -453,7 +523,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 } @@ -461,29 +531,38 @@ func blockbookAppInfoMetric(db *db.RocksDB, chain bchain.BlockChain, txCache *db if err != nil { return err } + subversion := si.Backend.Subversion + if subversion == "" { + // for coins without subversion (ETH) use ConsensusVersion as subversion in metrics + subversion = si.Backend.ConsensusVersion + } + metrics.BlockbookAppInfo.Reset() metrics.BlockbookAppInfo.With(common.Labels{ "blockbook_version": si.Blockbook.Version, "blockbook_commit": si.Blockbook.GitCommit, "blockbook_buildtime": si.Blockbook.BuildTime, "backend_version": si.Backend.Version, - "backend_subversion": si.Backend.Subversion, + "backend_subversion": subversion, "backend_protocol_version": si.Backend.ProtocolVersion}).Set(float64(0)) metrics.BackendBestHeight.Set(float64(si.Backend.Blocks)) + metrics.BackendTipAgeSeconds.Set(time.Since(is.GetBackendTipLastAdvance()).Seconds()) metrics.BlockbookBestHeight.Set(float64(si.Blockbook.BestHeight)) + synchronized := 0.0 + if si.Blockbook.InSync { + synchronized = 1 + } + metrics.Synchronized.Set(synchronized) 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) @@ -493,54 +572,69 @@ func newInternalState(coin, coinShortcut, coinLabel string, d *db.RocksDB) (*com } is.Host = name } - return is, nil -} -func tickAndDebounce(tickTime time.Duration, debounceTime time.Duration, input chan struct{}, f func()) { - timer := time.NewTimer(tickTime) - var firstDebounce time.Time -Loop: - for { - select { - case _, ok := <-input: - if !timer.Stop() { - <-timer.C - } - // exit loop on closed input channel - if !ok { - break Loop - } - if firstDebounce.IsZero() { - firstDebounce = time.Now() - } - // debounce for up to debounceTime period - // afterwards execute immediately - if firstDebounce.Add(debounceTime).After(time.Now()) { - timer.Reset(debounceTime) - } else { - timer.Reset(0) - } - case <-timer.C: - // do the action, if not in shutdown, then start the loop again - if atomic.LoadInt32(&inShutdown) == 0 { - f() + 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) + } + + // Balance-history transaction cap, split by transport. The shared + // _BALANCE_HISTORY_MAX_TXS is a backward-compatible fallback that sets + // the default for both surfaces; the transport-specific + // _WS_BALANCE_HISTORY_MAX_TXS / _REST_BALANCE_HISTORY_MAX_TXS then + // override their respective surface. + netUpper := strings.ToUpper(is.GetNetwork()) + parseMaxTxs := func(envVar string, def int) (int, error) { + if v, ok := os.LookupEnv(netUpper + envVar); ok { + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil || n < 0 { + return 0, errors.Errorf("%s%s: invalid value %q (want a non-negative integer, 0 to disable)", netUpper, envVar, v) } - timer.Reset(tickTime) - firstDebounce = time.Time{} + return n, nil } + return def, nil + } + wsDefault, restDefault := api.DefaultBalanceHistoryMaxTxsWS, api.DefaultBalanceHistoryMaxTxsREST + if shared, err := parseMaxTxs("_BALANCE_HISTORY_MAX_TXS", -1); err != nil { + return nil, err + } else if shared >= 0 { + wsDefault, restDefault = shared, shared } + if is.BalanceHistoryMaxTxsWS, err = parseMaxTxs("_WS_BALANCE_HISTORY_MAX_TXS", wsDefault); err != nil { + return nil, err + } + if is.BalanceHistoryMaxTxsREST, err = parseMaxTxs("_REST_BALANCE_HISTORY_MAX_TXS", restDefault); err != nil { + return nil, err + } + logMaxTxs := func(surface string, n int) { + if n > 0 { + glog.Info("BalanceHistoryMaxTxs ", surface, " limit ", n, " transactions per request") + } else { + glog.Info("BalanceHistoryMaxTxs ", surface, " unlimited") + } + } + logMaxTxs("WS", is.BalanceHistoryMaxTxsWS) + logMaxTxs("REST", is.BalanceHistoryMaxTxsREST) + return is, nil } 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 - 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)) } } @@ -548,21 +642,22 @@ 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) } } -func onNewFiatRatesTicker(ticker *db.CurrencyRatesTicker) { +func onNewFiatRatesTicker(ticker *common.CurrencyRatesTicker) { defer func() { if r := recover(); r != nil { glog.Error("onNewFiatRatesTicker recovered from panic: ", r) + debug.PrintStack() } }() for _, c := range callbacksOnNewFiatRatesTicker { @@ -574,7 +669,7 @@ func syncMempoolLoop() { defer close(chanSyncMempoolDone) glog.Info("syncMempoolLoop starting") // resync mempool about every minute if there are no chanSyncMempool requests, with debounce 1 second - tickAndDebounce(time.Duration(*resyncMempoolPeriodMs)*time.Millisecond, debounceResyncMempoolMs*time.Millisecond, chanSyncMempool, func() { + common.TickAndDebounce(time.Duration(*resyncMempoolPeriodMs)*time.Millisecond, debounceResyncMempoolMs*time.Millisecond, chanSyncMempool, func() { internalState.StartedMempoolSync() if count, err := mempool.Resync(); err != nil { glog.Error("syncMempoolLoop ", errors.ErrorStack(err)) @@ -587,12 +682,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() @@ -604,7 +698,7 @@ func storeInternalStateLoop() { } else { glog.Info("storeInternalStateLoop starting with db stats compute disabled") } - tickAndDebounce(storeInternalStatePeriodMs*time.Millisecond, (storeInternalStatePeriodMs-1)*time.Millisecond, chanStoreInternalState, func() { + common.TickAndDebounce(storeInternalStatePeriodMs*time.Millisecond, (storeInternalStatePeriodMs-1)*time.Millisecond, chanStoreInternalState, func() { if (*dbStatsPeriodHours) > 0 && !computeRunning && lastCompute.Add(computePeriod).Before(time.Now()) { computeRunning = true go func() { @@ -620,7 +714,9 @@ func storeInternalStateLoop() { glog.Error("storeInternalStateLoop ", errors.ErrorStack(err)) } if lastAppInfo.Add(logAppInfoPeriod).Before(time.Now()) { - glog.Info(index.GetMemoryStats()) + if glog.V(1) { + glog.Info(index.GetMemoryStats()) + } if err := blockbookAppInfoMetric(index, chain, txCache, internalState, metrics); err != nil { glog.Error("blockbookAppInfoMetric ", err) } @@ -630,17 +726,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 { @@ -654,23 +739,35 @@ func onNewTx(tx *bchain.MempoolTx) { func pushSynchronizationHandler(nt bchain.NotificationType) { glog.V(1).Info("MQ: notification ", nt) - if atomic.LoadInt32(&inShutdown) != 0 { + if common.IsInShutdown() { return } if nt == bchain.NotificationNewBlock { - chanSyncIndex <- struct{}{} + select { + case chanSyncIndex <- struct{}{}: + default: + } } else if nt == bchain.NotificationNewTx { - chanSyncMempool <- struct{}{} + select { + case chanSyncMempool <- struct{}{}: + default: + } } else { glog.Error("MQ: unknown notification sent") } } -func waitForSignalAndShutdown(internal *server.InternalServer, public *server.PublicServer, chain bchain.BlockChain, timeout time.Duration) { - sig := <-chanOsSignal - atomic.StoreInt32(&inShutdown, 1) - glog.Infof("shutdown: %v", sig) +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() + 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() @@ -693,57 +790,48 @@ func waitForSignalAndShutdown(internal *server.InternalServer, public *server.Pu } } -func printResult(txid string, vout int32, isOutput bool) error { - glog.Info(txid, vout, isOutput) - return nil -} - -func normalizeName(s string) string { - s = strings.ToLower(s) - s = strings.Replace(s, " ", "-", -1) - return s -} - // computeFeeStats computes fee distribution in defined blocks 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 } err = api.ComputeFeeStats(blockFrom, blockTo, stopCompute) - glog.Info("computeFeeStats, ", time.Since(start)) + glog.Info("computeFeeStats finished in ", time.Since(start)) return err } -func initFiatRatesDownloader(db *db.RocksDB, 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"` +// runStartupSelfHealing runs blocking, at-startup self-healing tasks once RocksDB is +// initialized. It blocks until done so the rest of startup proceeds against a consistent DB. +// Future self-healing tasks can be added here; today it reconciles missing historical fiat +// rates before the periodic downloader loops start. +func runStartupSelfHealing() { + if fiatRates != nil && fiatRates.Enabled { + // chanOsSignal is closed on shutdown, so a SIGTERM during a long reconciliation + // aborts it promptly instead of blocking startup-shutdown until the backfill ends. + fiatRates.ReconcileHistoricalRatesAtStartup(chanOsSignal) } +} - err = json.Unmarshal(data, &config) - if err != nil { - glog.Errorf("Error parsing config file %v, %v", configfile, err) - return +func initDownloaders(db *db.RocksDB, chain bchain.BlockChain, config *common.Config) { + if fiatRates.Enabled { + // Block on startup self-healing before the periodic loops begin, so historical gaps + // are repaired via the CDN without contending with the Free-tier tip loop. + runStartupSelfHealing() + go fiatRates.RunDownloader() } - if config.FiatRates == "" || config.FiatRatesParams == "" { - glog.Infof("FiatRates config (%v) is empty, so the functionality is disabled.", configfile) - } else { - fiatRates, err := fiat.NewFiatRatesDownloader(db, config.FiatRates, config.FiatRatesParams, nil, onNewFiatRatesTicker) + if config.FourByteSignatures != "" && chain.GetChainParser().GetChainType() == bchain.ChainEthereumType { + fbsd, err := fourbyte.NewFourByteSignaturesDownloader(db, config.FourByteSignatures) if err != nil { - glog.Errorf("NewFiatRatesDownloader Init error: %v", err) - return + glog.Errorf("NewFourByteSignaturesDownloader Init error: %v", err) + } else { + glog.Infof("Starting FourByteSignatures downloader...") + go fbsd.Run() } - glog.Infof("Starting %v FiatRates downloader...", config.FiatRates) - go fiatRates.Run() + } + } diff --git a/build/bb-build-var-prefixes.txt b/build/bb-build-var-prefixes.txt new file mode 100644 index 0000000000..c3c55090b6 --- /dev/null +++ b/build/bb-build-var-prefixes.txt @@ -0,0 +1,20 @@ +# Canonical list of build-time Blockbook variable prefixes that carry a +# suffix (e.g. BB_DEV_RPC_URL_HTTP_ethereum). +# +# Single source of truth, consumed by: +# - .github/actions/export-env-vars/action.yml (coin-alias normalization) +# - Makefile (forwarding overrides into the build/test container) +# - build/tools/templates.go (a subset; guarded by TestCanonicalPrefixesCoverTemplateConsumers) +# +# One prefix per line; blank lines and lines starting with '#' are ignored. +# The standalone BB_BUILD_ENV (no alias suffix) is handled separately by each consumer. +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_ diff --git a/build/docker/bin/Dockerfile b/build/docker/bin/Dockerfile index 227d903bb3..11c27e4e00 100644 --- a/build/docker/bin/Dockerfile +++ b/build/docker/bin/Dockerfile @@ -6,17 +6,17 @@ ARG PORTABLE_ROCKSDB RUN apt-get update && \ apt-get upgrade -y && \ - apt-get install -y -f build-essential git wget pkg-config lxc-dev libzmq3-dev \ + apt-get install -y build-essential git wget pkg-config lxc-dev libzmq3-dev \ libgflags-dev libsnappy-dev zlib1g-dev libbz2-dev \ - liblz4-dev graphviz && \ + libzstd-dev liblz4-dev graphviz && \ apt-get clean ARG GOLANG_VERSION -ENV GOLANG_VERSION=go1.22.8 -ENV ROCKSDB_VERSION=v6.22.1 +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" -ENV CGO_LDFLAGS="-L/opt/rocksdb -ldl -lrocksdb -lstdc++ -lm -lz -lbz2 -lsnappy -llz4" +ENV CGO_LDFLAGS="-L/opt/rocksdb -ldl -lrocksdb -lstdc++ -lm -lz -lbz2 -lsnappy -llz4 -lzstd" ARG TCMALLOC RUN mkdir /build @@ -29,27 +29,32 @@ fi # install and configure go ARG TARGETPLATFORM -RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then ARCHITECTURE=amd64; elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then ARCHITECTURE=arm64; else ARCHITECTURE=amd64; fi \ - && cd /opt && wget https://dl.google.com/go/$GOLANG_VERSION.linux-$ARCHITECTURE.tar.gz && \ - tar xf $GOLANG_VERSION.linux-$ARCHITECTURE.tar.gz +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then ARCHITECTURE=amd64; GO_SHA256=9fa5ffeda4170de60f67f3aa0f824e426421ba724c21e133c1e35d6159ca1bec; \ + elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then ARCHITECTURE=arm64; GO_SHA256=a68e86d4b72c2c2fecf7dfed667680b6c2a071221bbdb6913cf83ce3f80d9ff0; \ + elif [ "$TARGETPLATFORM" = "linux/aarch64" ]; then ARCHITECTURE=arm64; GO_SHA256=a68e86d4b72c2c2fecf7dfed667680b6c2a071221bbdb6913cf83ce3f80d9ff0; \ + else ARCHITECTURE=amd64; GO_SHA256=9fa5ffeda4170de60f67f3aa0f824e426421ba724c21e133c1e35d6159ca1bec; fi \ + && cd /opt && wget https://dl.google.com/go/$GOLANG_VERSION.linux-$ARCHITECTURE.tar.gz \ + && echo "$GO_SHA256 $GOLANG_VERSION.linux-$ARCHITECTURE.tar.gz" | sha256sum -c - \ + && tar xf $GOLANG_VERSION.linux-$ARCHITECTURE.tar.gz RUN ln -s /opt/go/bin/go /usr/bin/go RUN mkdir -p $GOPATH RUN echo -n "GO version: " && go version 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 && git clone -b $ROCKSDB_VERSION --depth 1 https://github.com/facebook/rocksdb.git \ + && test "$(git -C /opt/rocksdb rev-parse HEAD)" = "ae8fb3e5000e46d8d4c9dbf3a36019c0aaceebff" +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 # pre-load depencencies RUN \ - cleanup() { rm -rf $GOPATH/src/github.com/syscoin ; } && \ + cleanup() { rm -rf $GOPATH/src/github.com/trezor ; } && \ trap cleanup EXIT && \ - mkdir -p $GOPATH/src/github.com/syscoin && \ - cd $GOPATH/src/github.com/syscoin && \ - git clone https://github.com/syscoin/blockbook.git && \ + mkdir -p $GOPATH/src/github.com/trezor && \ + cd $GOPATH/src/github.com/trezor && \ + git clone https://github.com/trezor/blockbook.git && \ cd blockbook && \ go mod download diff --git a/build/docker/bin/Makefile b/build/docker/bin/Makefile index 7b2623233c..4949d1ea00 100644 --- a/build/docker/bin/Makefile +++ b/build/docker/bin/Makefile @@ -1,21 +1,21 @@ SHELL = /bin/bash VERSION ?= devel -GITCOMMIT = $(shell cd /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/syscoin/blockbook/common.version=$(VERSION) -X github.com/syscoin/blockbook/common.gitcommit=$(GITCOMMIT) -X github.com/syscoin/blockbook/common.buildtime=$(BUILDTIME) -BLOCKBOOK_BASE := $(GOPATH)/src/github.com/syscoin +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 BLOCKBOOK_SRC := $(BLOCKBOOK_BASE)/blockbook ARGS ?= all: build tools build: prepare-sources - cd $(BLOCKBOOK_SRC) && go build -tags rocksdb_6_16 -o $(CURDIR)/blockbook -ldflags="-s -w $(LDFLAGS)" $(ARGS) + cd $(BLOCKBOOK_SRC) && go build -o $(CURDIR)/blockbook -ldflags="-s -w $(LDFLAGS)" $(ARGS) cp $(CURDIR)/blockbook /out/blockbook chown $(PACKAGER) /out/blockbook build-debug: prepare-sources - cd $(BLOCKBOOK_SRC) && go build -tags rocksdb_6_16 -o $(CURDIR)/blockbook -ldflags="$(LDFLAGS)" $(ARGS) + cd $(BLOCKBOOK_SRC) && go build -o $(CURDIR)/blockbook -ldflags="$(LDFLAGS)" $(ARGS) cp $(CURDIR)/blockbook /out/blockbook chown $(PACKAGER) /out/blockbook @@ -24,13 +24,20 @@ tools: chown $(PACKAGER) /out/{ldb,sst_dump} test: prepare-sources - cd $(BLOCKBOOK_SRC) && go test -tags 'rocksdb_6_16 unittest' `go list ./... | grep -vP '^github.com/syscoin/blockbook/(contrib|tests)'` $(ARGS) + 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 'rocksdb_6_16 integration' `go list github.com/syscoin/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 + @echo "Go API e2e tests were removed. Run contrib/tests/run-openapi-tests.sh from the repository root." + @false + +test-connectivity: prepare-sources + cd $(BLOCKBOOK_SRC) && go test -tags 'integration' github.com/trezor/blockbook/tests -run "$${CONNECTIVITY_REGEX:-TestIntegration/.*/connectivity}" -timeout 3m $(ARGS) test-all: prepare-sources - cd $(BLOCKBOOK_SRC) && go test -tags 'rocksdb_6_16 unittest integration' `go list ./... | grep -v '^github.com/syscoin/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) diff --git a/build/docker/deb/Dockerfile b/build/docker/deb/Dockerfile index 55989099ab..a6df616948 100644 --- a/build/docker/deb/Dockerfile +++ b/build/docker/deb/Dockerfile @@ -6,9 +6,22 @@ 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 +ARG DOCKER_SHA256 + +RUN if [ -z "$DOCKER_VERSION" ]; then echo "DOCKER_VERSION is a required build arg" && exit 1; fi +RUN if [ -z "$DOCKER_SHA256" ]; then echo "DOCKER_SHA256 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" && \ + echo "${DOCKER_SHA256} docker.tgz" | sha256sum -c - && \ + 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/build-deb.sh b/build/docker/deb/build-deb.sh index 2ee703f127..9eafe13c0d 100755 --- a/build/docker/deb/build-deb.sh +++ b/build/docker/deb/build-deb.sh @@ -15,7 +15,7 @@ mkdir -p build cp -r /src/build/templates build cp -r /src/build/scripts build cp -r /src/configs . -mkdir -p /go/src/github.com/syscoin/blockbook/build && cp -r /src/build/tools /go/src/github.com/syscoin/blockbook/build/tools +mkdir -p /go/src/github.com/trezor/blockbook/build && cp -r /src/build/tools /go/src/github.com/trezor/blockbook/build/tools go env -w GO111MODULE=off go run build/templates/generate.go $coin go env -w GO111MODULE=auto @@ -30,6 +30,7 @@ if ([ $package = "blockbook" ] || [ $package = "all" ]) && [ -d build/pkg-defs/b export VERSION=$(cd build/pkg-defs/blockbook && dpkg-parsechangelog | sed -rne 's/^Version: ([0-9.]+)([-+~].+)?$/\1/p') cp Makefile ldb sst_dump build/pkg-defs/blockbook + cp /src/openapi.yaml build/pkg-defs/blockbook cp -r /src/static build/pkg-defs/blockbook mkdir build/pkg-defs/blockbook/cert && cp /src/server/testcert.* build/pkg-defs/blockbook/cert (cd build/pkg-defs/blockbook && dpkg-buildpackage -b -us -uc $@) diff --git a/build/docker/deb/gpg-keys/avalanche-releases.asc b/build/docker/deb/gpg-keys/avalanche-releases.asc new file mode 100644 index 0000000000..62d4236abe --- /dev/null +++ b/build/docker/deb/gpg-keys/avalanche-releases.asc @@ -0,0 +1,51 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBF7T6r4BEACmdzpthz6plCzb36P5Fx+Jwmm2/SlfKoXAEnJhjP+oK7PAvak+ +2sDFVv+MI5+nxJXQOdVat5/d9YlbwvgTQ4v/Iz0rJdSpjqKwUaJsDuHTedx6e/VA +y7hYXwEcsLjiK9Ws7f9Oem1fvPa6tbLGQ5oSM0B8OKXAiR/YGcA+pC7SKURbbdY3 +hfwRljeJ3/Uq8CTuDw1VsOqEELqlCED8VuSWG4CTOyU+KvV0jIB9TrRB0U/jb+XB +m/Jon/ZQFT5miPT+8VUa6L3WkVW9d97kiPhdH0d4yASoQ4AFiLpaEXXVWT9Nhvpg +VMYYxscA3EnjaTXBR/kUfgmlbXfkiHv8reawynxGLwupK+qpH0XJbawJ7Hgvhej1 +SkU3WxhWuKdD92t3JXnWtJTiqQA42DPf5Bl1p3o7TRWMz57GRc8H7l/DdlZ1FbMJ +TylAC+MJc5InJP//kZURMuKnsLkX4WyfF13fxb4oNXUt0wojdUvTQYnPDNFxW2Nr +ddAtT+VoeRXQtIk6YjR+WkTF6/XYbSq734e0gLKC4aDd3EvbpYT/ZqpGr1VOfhve +dIWbkHhqBtTHJQQy5ET7PiduN7S2OQGtuYUVzLp71iLAOK7hWOo5vWYDlwphK+uB +MuGSdsnmtpceGOaqANVtpFmLsPAGSzxMQ368lnefCas3/ybvglu0M3YD1QARAQAB +tCRBdmEgTGFicywgSW5jLiA8Y29udGFjdEBhdmFsYWJzLm9yZz6JAk4EEwEIADgW +IQRTlb2hEpysPnRpnQfZMICwwNX+iwUCXtPqvgIbAwULCQgHAwUVCgkICwUWAgMB +AAIeAQIXgAAKCRDZMICwwNX+iz+jEACElYnwV7VHgF70W2yJqPmJGZXswWlid6w4 +NTVOUV9jZTtIH88Jb81K03cOrzREXMX7H4qfEcsa2GM9s6n1IZJl+emaSCr3Z/sV +aNR4vSRWT6/IrvbSpVYdh3XOVjA11464ldr5VAtjh528pHfzR2krzhuMOaQL8r27 +TuiUDBjkAVBoWHwN9nahj0OU56r00Mr4fZUWFuNBRgpaQoB6FwjQ1xtoedgN97iI +QPwqEJdUzXQasl+K62TV7F8HUT2+25cEsu+w6OPSsERRgZLv3OHiEbO+ld9+KWDv +QywcTjIBHCF/5qL2gfx3TQ7KwxrUEquOm/QJL4mz9HwfLLaR6Etg1NhMFooTdstQ +tAJK6eUmRQk1psFP3yGbdSElkZ/RfbZvYcjm+x/r70Xo9T3Rv8fvIg6fqjnApmqW +7BT59iFUOWrATEUAi393hem81njll+fBxd3p+4ilhAEz39Z5NoHX0ddktnSa81Pz +4yKOJpcOB/mo7UwOoZ0iKND+ZSZxeo7YWlHZ5cSqRGyW5C/1PsLfqdRDw8SBO6A5 +rnjeppB+jiaPIqqKLGYRSV24H5PM9F7Uh1/H1lAIwXoxbSvkTelV0fTC+Bb9uPvu +AdLxDf/TiLnRL7zsV/buHamrO83fh772kVjwNvverNuNcpcBTHuAHruqT9jcMmv3 +IU46JdCefLkCDQRe0+q+ARAA8JGJzbWdn2vz1gtEhKLLe3gDcncr3wrqCF+joEQ6 +1XKE0vrgycLh79YMgBtkH9IXsP07KUXQ+xjSbW0SsHGYtANg16JxdRdDiJfkPl0R +ilALkuLvOrwLh2GJN1L7YggcWV/RTQ2NRpUFAdKQVU4x9N+E3QTRkVzcn2p/eiQh +cT4c4itWke7J17CvXwVRnSOMuse23wEj/Y5BHhLYn13ojknAK3UHXJGRVm5C0M14 +D9rTJBCrI03pBnBzQhxkXSbeC++vevU/DBN19ph6Mq6dv6VUP5Ai996Cs1IDfbB8 +CNW/IjJ+GSkciIurSFNXyFKa6ppSowYdxB1Fqabyx1isKGASfzNooCByVuj7xhFM +QjrAL2ZtOC1vjjUeuQ9ZpAmBa842xukJ8/ffvbvFoFXOOpJEJ9VZP2l5GWmanr/a +MkVvykssGmH8vLWytWa8J0QeA0FEFyiNc3kfkozIjScI90RdEj+ddp8z9H09/ml8 +xr1Xj07w3zz20/rRIdmpVTpo44jF0jV0h7n33dg+LWX7iDU+NpalfX0DH/sEuzEa +RYZsR09ExxLWBdHZ8HWIFRFRIrEg45oa7umo+YxypwrQFSxDT6v0QTPgle1Ged7h +f47filRbrHhXhVxKWLPJok+Bme0NOAXFs1bSw3xCnkL4RlSckrPrUwQPGLqNQ6hd +rrUAEQEAAYkCNgQYAQgAIBYhBFOVvaESnKw+dGmdB9kwgLDA1f6LBQJe0+q+AhsM +AAoJENkwgLDA1f6LgAcP/3YGbQY753108CRdVA+R8RQRKF8Wl7rBUNnXXCnuJ0j5 +4JaeqOkX5JQtEEnIMJZdpOFF6iK9LH9afbCUMiV/BLYbo1qPTAMgVVp8vfDkq4GT +wk4QIo3fLUEGW01IT93OY/OH4FsC8NcN9+mWnoiwlvyIfR7IauDbmbQ6eDzKr8r4 +K4am45eSPPOrS+W2+LjpjIMGzr971jL+FLeXoejiX0SeHJ/YGpv/qJnVZW3lVE9u +nomutcMCzbceZJuU4f6NoUDjGVEnNr3eUvPXPyrD53mD2G4C2H/pRteS6Oo7gsaH +Y2HfTngO8xVrMKQA6qGxilhYNSF0narDb7I+MCMTG0eGyGhixElmd2smmjbAzKY8 +dkMI7JYbhzbpfUVmiFhVEbr1mcCC8ZgkVCFmvw+Phbh29EUsMi9vQaHAzp/yZQ2+ +duBUPalYe+4ZPujq9QlvqLxUyDoqUg31TLaHHsvinByo6ZNXQ1NzGuYbVy1CYlK8 +9LqRPKFrO9bLsgwpE8pGkaOCl/1eATGru2b3jfwenknEBxvnXpexHLdUaJDcnBjH +nqpSPI6o3NXVvLp5On2ygXBNEQTbcCrMxs1YSafXwlWyteYXdBHSBOPSfhoscYLF +mvjNjlpX8tBPVnwHjpdxP+XitvrN/MVmGaMMfe+WDRRdUiup1Mj4ha8BzUggbGoq +=AzIy +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file 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/docker/deb/gpg-keys/litecoin-releases.asc b/build/docker/deb/gpg-keys/litecoin-releases.asc index 5a7c1c1d32..59f9d04a5e 100644 --- a/build/docker/deb/gpg-keys/litecoin-releases.asc +++ b/build/docker/deb/gpg-keys/litecoin-releases.asc @@ -1,113 +1,52 @@ -----BEGIN PGP PUBLIC KEY BLOCK----- -Version: SKS 1.1.6 -Comment: Hostname: pgp.mit.edu -mQENBFHBpq4BCAClUHF5fvpm1V0dxM1QKenkqeOl7w0EJ2MSZ26nzzH22yVOvwED5h/7/Lb+ -o6QyPf/89uEPsPi4paPzgkDPT+CoZAkjKyzWy2YW/m2wHWoXWw1xSJqlqxFogmrq3ZHbjnxY -OjAA4KsGpIijbLUAxOaAl5dkOCDEFl0KiKZzrXJNnYlbFef0fqj10QVW+o5uV9wYH6UMoc2x -4yVucpLyJJVy25Qz33dqcG+nYdsT+jAPVG2Fcig/WlHZ2fQFloH3mThOa6PIHbym1YzjzLRL -XH/oobE9RASpdwbsivVTUfq49B7BecKCuwPRCWnv5es+dfRZrPsoipckB3ZNLQIy618TABEB -AAG0MUFkcmlhbiBHYWxsYWdoZXIgPHRocmFzaGVyQGFkZGljdGlvbnNvZnR3YXJlLmNvbT6I -RgQQEQIABgUCWZ3qMAAKCRCICdYYa/fgYRDKAJ9zrgabNqBY12ISad2Ser2W1y/69ACdHYf5 -nCFNASqOqjIsHushrKDshaCIdQQQFggAHRYhBBGhZrZZ0+gl6WUvoRu4nAYCNnRJBQJZ9Hd2 -AAoJEBu4nAYCNnRJmrcA+wZ7cHsfau4k86Pme4UrcHZuEIj+IA9idC0zpcMETySlAP43lt95 -Q9/pQj+ejo9hgmDFKGbrN7f3cFvceR21DzE5A4kBHAQQAQgABgUCWQTuWAAKCRAxqJGXyCK9 -6SR7B/9/VSiPknrlpxesUXAeZmFa3wi5rNng6j+nizvm8g3cP0/9eJFGkRwA7caSUPoYzV1V -Od+phXliK7H4MqUy/j64i29aeCPkGGGnKy/yBtcLZGziANPdyMNnLB1Jtm1lUyAlE/mc7ykn -3nzelyknJkKR8ZLkDRiGr477GtBnCaSj8gcDFEgoFtqcuJoCPvyBCk5JBvqtSHquZtmQDQOv -Tzf7enDKueV7mlec5DQpNIlqcwSWeQQFWHgxHLW3SA8Jrbp31SL6cWRFMdKRgz8hVNWzJgTv -wWRMt5lpAZHdt9VzR2f2E2O6euzf3o7nV+wybFdkopLbkFlo9FNFBGyjGhb7iQEzBBABCAAd -FiEELwVV4sOsclMharKsM8pDf89sOawFAlqKPE8ACgkQM8pDf89sOaximgf/VteKyTn6tMHd -rlmc7F3kjSV7OBN7lUroypNLsJHcLE1yZjndzaC9YDW3oBtatynwnHRKDMnDBDMjtMmeNMG/ -Fyv7Kf1NzSquMcijKvqBXJ5pOvdPU60/Ut6LqpVSjKzqEfGI3HReRXVnFrukXQW7sYbUKr5M -4Tej9Jn/0knjwOQ/gFOtC7Jk9RMLrVqW+m6h47FxEWL73O7+c8wFFsfaY2slDEmNEA099rHZ -x/YyYnUM/h23QrXy4ZkgQ9cK+/InPikg4hBAh9NWiEuV5oP6QL26P20LX7+QxkTZ8t/5X5P+ -lvgafH116u1PuaqIYZ2CLYrPw0V13VNMJp8WX4I5Y4kBMwQQAQgAHRYhBDJRMi1ApDXXYncE -hLyylIlKy6GLBQJbCzocAAoJELyylIlKy6GLnUIIAMDT2RSUxc8mLZeQg9IPeiEzxBSRRTCM -5VYluLaHWoO89RWZ0Tir5KWyxwzTtJjXDX46Jmk4pPsk6QgkVgxVO0MuIlZNhlGR76234rF+ -jbSE+py+W57ksnLodSvhfcO73jyGLq5t5Q1bJIHD/gtGln1V22mZLxLJzokqP71I0wdP0met -XwzD4LFidB3pbDXLBGSRnrMo3Pf+0L9osX9UFPJ/2PlYRwjS08aP3F3mKvmzq416g76zkpIQ -/XtWuWl619Y7Uv5MWFaez9+kKnaF18H4ZC4gPp+PuRFjXDDwteNYpe3oQwJZAyiFoZdqPyfo -PjA5dsRx9ZiR+eKaiOH1KPGJATMEEAEIAB0WIQRG9EPpEZE2fND5JZ848WoHDXz1mgUCWlRk -yQAKCRA48WoHDXz1mlZzB/9dqMxlUR8AEHqPN1cEG4BLoKteUXDrQit6HvHR0YiKZ/yaBXfU -hL2ogGAifZeRmQF5pvk90FPPJL4w98fZkd4tbo/5nkxzcV2bxArBdB6iT5VTULOkI08ykZyI -eZzxf8GrwBnqoGa0UShW52mCberw5AHUkGdCGfYFAwlIMd+VY8RVkSw+H34GiCQz99nq1zmM -AgOw4nG5bgr+Hrqg/e6Jo+lotDWdmmXe1aNLtCv8OHg6lf/goPuFQCXKDAqCq0DGsXLGBU+d -WZ0GlR3LVL629yxTjCYiiwB3IcDm/8MF5CUnfKa5YmgsQYLxLCv+oHFWNc9xFT/wsKA3myms -0LP0iQEzBBABCAAdFiEEbARr2V4npmP4mdO0hLqkPJrX+90FAlqH1JYACgkQhLqkPJrX+92B -1AgAqlLbYsfkw/oHW/7q1/A8nKtMY7SxtAmkAZvO8CmSb8I0/kcfn/4jzfO9eAODOh+8JC/a -PhvrrBstY23OQyYrE1hED+kwc/gCm/B9ZqaqrFd2jnDa3yP4/G0laf2p/bSk+SVB1bptqqM2 -V1q9ugJPKAyDLTUnL1XWeXjYr5pFEQkiYQN6ZKX2j7PN6ywQ5mPq4LB4r74hV7ogCjuu5SIN -3XW1UMHr1Dv2mJFkl5LROnHlHeMliC+x99TUPN+h7hY9TpeebscpaJdmZiFO6LWZeJN41Kbs -mEuKXzvlpo6Eyc02xPQseB29jRpuQ/ZSFRtkhrwgyCEsL4JrCqvzaj1UDIkBOAQTAQIAIgUC -UcGmrgIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ/jNIh3gJOGz/kAf/eNhLqEXc -/Yi5d+aw7xYbFxk0fH5pPANwB2kCvg9D1plZds/AEP1wPFuomQUDrThxl9PZisvSxGs1fLP1 -q4VNpLEzqdawa4jrTJ3b5H4N9GjuKsCwmAIbVPPHldg65BeWGqFKmri2e/mU28lzMGWKMLYb -u5fttQq8834CqapYy4JxLs97Otvl3THkkkpCHDiD5yFCPxuYPnuQUQtirjJdXHtatqK2CfyX -6nNEFNUTADKjI2h4uIYALHwh6EXH8/rssUK2aYJpo31TtcZfvt+uHMSCmEr3DWu/F13xVrUX -xqAB3KRzO3HIbm02fUfDAaT7EDnjzaeUMpC/pTq2GLjhnokCHAQQAQIABgUCWaYdNgAKCRB1 -Uu55U8MCrxzpEAC9sVaz97ce5bBDq5mmbC0oK+UgDNNuCJnkResxd17Hh53dAbX7lKgD3opC -B2AfRskp2NmDhW4FZh7TJ0WuOvM7PaeXU2TGpJVJXE/7Bwf2aQgwD/DG691PrRXErWSY/Cn9 -Lfv7p3LOgsYZ9T6C/zPKoAzru2TsWve/NHuXezCZVZIUy7bNoH9nXjf9nl142QCLSsBw18dg -BlxnncKpcWMOcoXdri92YkNcpxVNSKAHrBtor7QibsiY03ZLIqY/+ihb3dv5VMpDFTKR0D3f -iFrHGxKrLpM39GuaUsLCRFTUDvaDOZGwucJPFiVRcO5slt/X72jikARxghJpA9WXAgwLKEna -6gdlsoKSHXQbqg9goroTw75+z+R8YF/1ioH9wvPZvHDgA1YFEvDHWKi9pGp4LufHAm42DkX8 -FxZ7Dstlek1VuNegn1uNSV16nsV5jpxZTtFDNJU1Usmn+cRhSACrRIoMFkFOmlKkuuyGl6UC -0bKzOzQB7e9BktorRlfW2iTLh25dx2N6jWyvzDUTIHblY765XSbsvEK4heL7lYJAEXBqbbng -QGQqKZyFGTwP38cXTLSvqxMq1nXYeNi8JlVPpujknt7HrkEZ/HkG1Ss6yxS6RDCJHI7ChY4F -ZQ4cmh+pTLs6sqFTolOEBwWU0zs3aYrY2B/82i3+v8rxr31FLIkCHAQQAQgABgUCVbb3MAAK -CRAp4EebmcijlxGfEACjVN5eaDNw5fNmu33Dc+fKAcLC8ZzoID8uD2mn5YfL7o0htznLkkWb -jyDDyrlOMzHSrfav80Vh0LHC8Ep79ueC3o2R9+5XpnV/rGzXvOzbGzj328SyT7PDD7exwZhk -/O982Ohn3XPSVLW2SPHRhJk3W8nB82NTy/Pr5CPW0o+JViu+rwIc6TmeOBCmmIPsAcK9K87F -+ff5ol6nH3gVZ3V+dkugNRWB5oiXbqt967c3gnP9sbfpH+cll0Ss0CPeZWv8hNI5QYCxcmmY -k2/56Ca8vWKwnwnp+CeIyrZEqCycU0rAgXD6EN565D17kNQjhIQ/XE2QjPSLl7nYS/l06F4p -noIV4tZ/1RImmSxpUr1dpG27/hJFUQvSqRjJeIKNWw06UIOTT42jm3Z08fnqtAtDuzOWjBnu -2W1fuUxk6aeMQ5FOBOR5fSRBi3lynkV58Fw+mmqqUnYChGbp1h17RpAWNpX1/Sxk0TydFOLU -pMDXc5hd560nrcT730zHmS1gKd7XZ9IBPMbjlozxFru9T5mTalvvZABE0eVh+PCsLXen1n2d -Niy6J40Zr/rfV9Qv3L0mIBRsnW5aa/n3FTRk6CSBppx9lKVBKqWFUeMq63c3BVqYo7Qkjl+7 -AxjTJOm7uS2Ami4L3xA3vOe0PXlK7tDit1HFGGmVYGOvITXo4VF9BYkCHAQQAQgABgUCWEXv -XAAKCRCU1PHssSX7ePQ5D/oD9QkGV+STXqPXmZhjzwrBsAQ7wMS5GLkCMQK68ZYMgBnRDO0p -TLgLgg5t7ofbIlkf2PaBj21fY41Pn9LE6IDWpOXi64a4uvGqM/Pym4+w4fjZc1z19CaGqbgQ -1p9tblnQHIMf0BJQnczAVXPgD/LgoS/ExXY1U6bbJnDcEh8JzKBG3VmG8fTewdiRPapJPcvc -kiHKWvF5xCm+qxnuVLXHw5Z+4wdfG+O3yhsxkmDPYkrkrHvskGEshkh5HsyPxCjh7LymEf+K -+VvVJzA3nujXiJ0luYO9Q2BmhUDxmjMBFMAa1iajRvQdPS5lgEdGZrgfpqf/7GeEiC+B+NUu -BEEv1etDreYIzXdpMP1QrBSZ/lYrU+ixSIClW5ip8EXgYCHpvX2eMEN7H5F/USB8IJm6mKNn -ItNtqfMHuU3jWJtHWzpjGhNchXpBWMp4/zaGyikgmYwHNTI6RkGOPxK8xl/NI+IVm92OsVHe -ZkkXEHjlZj5PF/OH7MmXVYFllrrI2LmCv6PzBAPN8Q7xjJqU1T9bbEBXTonPVwyY6HCQNvfK -zlD4XGHezJQZrSDqV2mrcLUllfHS5AGOeTuJbInC5DALfjg1bp+LwuxeYxBC242GRVKxYZLZ -3lcGuys6PafHTFifsrKHnZd9HFAYeLKjU5v5itLKRwXA1S15p0BSZPMFUYkCHAQQAQoABgUC -WaY8LQAKCRCJYv5x2Ah/g23yD/4m0DtxrhSHbQC0NUlLsvjgpK1BKFfVswpoLMUozqsINXyA -xu3vxXUdjJxP5V/c5nzTO1U4N3s/qcfUBxrTi22kZURGKBZ8G/xaVXQuHfH2soGWVhS6ogs7 -nD3cSk8qVgJy/g5fzyB5QIXnDwzCuPvW8X+shGiDFRpETBY2Zy3twRxleXY5dK1lWNYsp+H9 -tnZ1g0efQ73s6kTfUwOs8TlRAx7mJjba40KF1VEeoulSkf+sMcslKrS2/5QuuQQDcoRJRVXb -U75hVh5rarwnnaeNcbiPLee6ZWwJjcLwbRTPEhQxNE5aQsVcHnjmyNAlkZp11T9p80TOd7HN -aM1c2G8vme6BvlzzxHK27oQEP2Gg2UZxbXfCtwT0qc+w9Wh3P54EEzdJPsVmWkLaAUmUMfu8 -eJkHrxIPI++5noXHFP2MAhobnQxEBhapH9tD5zkPkia3Y1nh4UqgODokM8ZJHeQ/teUEl/ZX -cVQdpVQ/Cjxni8BIbehXbYZjrjnuU+EbWQ+LRYPWUYwaqBpuUIv57k/526asFDBEvVaPJ4bz -vTvdDrID7n3NAENbUcGx4zP9Ya+TEi5dsscIJYx6muxkMVODFUUqz1ijQLa9D3+5UeB1Y+uV -U+EDkCwv3weLUKX15HWbetB8cQT7sQOraNHZshmjwKv08lfYTXFxgBO/HY80uokCMwQQAQgA -HRYhBEW+vuyVCr0Fzw71w1CgTQw7ZRfyBQJbKiNbAAoJEFCgTQw7ZRfyi4cP/3AKeB5MtbNm -jTbc2m5OGB2be1386kciWbXBpmDiStmF13NO0yrbHKpyAGlcGBupBcOvl1WYPfO4ddLOb8QI -ijDqyHS0QHwp2ubFpq4HbEdKr2DZk/Ij1nZA4uqYt8Jod7DS1UC7QIee0LKZKGwAzd6Wnq8d -cEwrcfGnIF81KVBqNLXW5Wsf4ibTKkJgfcgmRP52P/y2lR0LctQHzr9PB6rwt4aVeQk6EoAX -E1Wcq5WiJzE9TFEovFulKgElvrHdpZJ4wr8yVNpSpAg5mqaTgOtroujl/TQzuTZF5KxLV4Ub -SLdnABYWPBzmr/XKwaMY6zJLgF8iJNuPiSymSN8g7OPvSSKWT3sdpKC0CT15vML3x1U54AN3 -d2QVBwYOpQQPcHu96bsZcVvzH1qCnZY6XGedl0Kyw+gVjMZJHRcIGaS/WcV4Se4ziXkNTdgH -+fHmEouxR8z2zjsL0ehdyhGNNcIhvumJs0/YhjwyypfctncpL8xs0Fl+OlX4cOOnlcrBC0us -rMGcywdT9VqQX/1Z7hqREggBVIbjtRXLuoKrTaGdlb1og/p5NmDk1fzQoY3SvzZy9NMSoW/F -zsqPw7KqRNn0fBXL751tXOxzwOOCBizZAJ1WD9tf8IkG5BAIFfJCFmRxCqxPHwlhcbJ+jZw8 -al468GSLF/EO4/R16WB9rbO/uQENBFHBpq4BCADhsnWwcoQ3oRwBWCEUxJMZ3dyxsRUfkoZs -jzAqG0goG2EukhKvWNfl/c3L5uXQepZ04Jald1yM55dd0UwCMC8CHSwpLOChP7JoRhzJ+P4M -XXUDkiYPo3UgbCMIRZ31NMuWTmLV16ZJmlGWjKtnAZIJGte56k7HeAz2GWrBKxXPyZapZcJa -+LspyydSPRWdiwPV88d7YSBwcjRV3Y8Sjw9LE/ddIt4fQEyeEO7CSfyK+6JDsKxsxlQFkLzG -ho/ORau1yTSwknd212lwLJGJVK8iYo0AFo4b6kfZnFWc+Ey4yOU8hl2rWKhPAf3rqL2LA/B6 -EQ4FQLbEA4ppLkvF8dEfABEBAAGIdQQQFggAHRYhBBGhZrZZ0+gl6WUvoRu4nAYCNnRJBQJZ -9Hd2AAoJEBu4nAYCNnRJmrcA+wZ7cHsfau4k86Pme4UrcHZuEIj+IA9idC0zpcMETySlAP43 -lt95Q9/pQj+ejo9hgmDFKGbrN7f3cFvceR21DzE5A4kBHwQYAQIACQUCUcGmrgIbDAAKCRD+ -M0iHeAk4bHGwB/96uN7K1MVO8dKQeq2avhrHQZCczGXB/0gRhWNj6njBJMdsfOtPypSqLWuC -CN107TRJkig+77lQ8JFhRGo+5QNt76fQL9a/VFbm1gTsAy3uL4hasHTUIrY7Uq1nDX6poHd2 -5wXWdEBbtiwAoCjp/gido69WS5lsga0S2e/IySx6Tel1pUO1hYUhUzSZYVFUjM/ncPJih+VM -T/3+kB4iY/SceNTx85gJSnucL+mXDuZTvxXui5tt4zGxSp+POHXBDduZliyxzKr5FTPGXw49 -3DiM3KggSieIDL6x3BWZR2U97w0iDbGWxS5mMJt+6FNCBJmeK2ooFRT+IJ6zeoXM0z6s -=OrL4 ------END PGP PUBLIC KEY BLOCK----- +mQINBFrhMHsBEADBwiXPnAuM63peMrCWIah0cJC26kp3EXPzfFvzzVC/4S5QwoGZ +BcFndFlLGnI0NWIbDe1YzdSMVx66U/G9HekNNq4SbtCGGxlCVMuQtu3hPKPBxEeD +W+0+kUa6ZknrKxCySCLcdsZLCSMbAmXjCz62bAuTsttvCTEsXoKjCGErrHlhDr+X +aOVxUU/pvx3AuuKqR/t0WmMPLDY5Ao3UjKZBniBFdtKeP0jAZLX8O4I3hN47xKyu +TzUYIMs5E/uYvkC3+iK0MOp+GkIFXmhKqOig0dTOHMa5Kf/ZzjCT3z6A6g6rRh9f +ak7gltBPACPPlbEbSuwa9hExDM1Mg0JzuU5HDD7pHTkZaLfEhby7ErLpkrn+pQkf +Pg1v/G+Jh/WZ32SG35uBSAXAFzZZAY4EbD+G/nlJrcS8BXrOhvtuDOX5HcG1XJ6K +Omxpg0d2OxI9jZXb9ibxZGbKeNAckkuNX2bfJtWnWLsruWpcPNRgTVVdjjhkZ+TH +r/QGVIcz8l2LBTUKAkCckM6RsWYGjJ818xm0qyihXsqtISIRxRpiUXwHkD1FSB+3 +uT7Wq8CLx3RnJnKga7F1wIbDDdI7ee8YZKGO9utRoPFE1aNo096hkGJQ/goA4wo7 +x6rjPvrEuq77H4AGcDkfZ5e02c8tDeusW+2Em/YKApPyZubfJsbEzn9BRQARAQAB +tChEYXZpZCBCdXJrZXR0IDxkYXZpZGJ1cmtldHQzOEBnbWFpbC5jb20+iQJUBBMB +CAA+FiEE01Yh1TocxqNFZ1jQNiDp04flVmYFAlrhMHsCGwMFCQeGH4AFCwkIBwIG +FQgJCgsCBBYCAwECHgECF4AACgkQNiDp04flVmYAaw//Uovt/PnmJNJJBgVxG8BS +sqqeyhJ1+ywfwWManql/XNJqCNXfDARTKUTv7lFUP2WeNg3Ze1R32l+fTS0q3D/3 +b3QxhtGfc4lOH8p1+5J426MXjcaPNRWA6GcQlALgwPbcFQDoN/kvgxconoXVax4f +NzZr6gA/dprf51kbdGIgEtK+z0pGCVxUR4NY5azT57s0+c7TRQ57OAmtMRF33Ino +JvqiMUqPSk/e/jeAPt91OE6Lenvf+i4oL5JMLjy5FzpAdPFGIfMCinezqPKb+Cbl +YpuQCeIO78wh/9ZT7JWJDh3ZXYgwt/jxL4bHerTab1uqimacuvmwPtcYYd8Bf6JI +woh69f1Gf63ggKz6NSquw01SW3b6m9lPO837hNx4Af11slAbEeCpSIuFvJpqBADa +vZEDzLOYAr0pRy/vTeOfcG6TvmDYyaZ4581LBlydpM/9aBGUCxT20iEL5HSTM5i1 +MDb6sQnxoBb9u/sYaMeIbY2MxdeD+BKQUD0SQdLEdOEDFkiaKbupyjjRFtney6zs +H1jYFGmwkkYAWkC6XFz0OP37kM5UXZ7Vgdk8VyhBgdKJJFStNmlR7KvCtjUoWAYV +IWq3qjfSz7e9TCpU1SWr0INTdvq7qBW3KWzi2Y5caVFfozBydCO1bSqsWbXoErb0 +7cSkui8REepYXk7pycUwK0O5Ag0EWuEwewEQALta19GNu3xQZtU7PTFNm3kZvEfC +1937l83mXVZBCbVBksjq9qDR3K7Z3zfPvc0H9jUUe7F9xOEUQxT3pv/Ml7QfTvgb +6qa5GkKzYMqDihI2eKv+h5vpfDnlyfA5TRgJh2Yq/utIp44WrOC9wCL0HsTut8o0 +FJd87YWZEOwPcsMTcZ3l8fChqfTv7O5TxyPiqwS5X93Q5MZaleupjiA/C58OZSGo +qXqq21skRI1n3X3SIln5jAD0H9oqKFNLM2AvDQfAAkHeRTGyg9O7AGzlkEvmKHPg +ySMOji4NLlLJQlbB4yw7Osd4tvtdsyyStDzySigisMMq8pWQ1/Qel1YZY36sJ8JQ +Wk5kyh/ImlrdGQgWEzoFEfcE5yF4k9D7v7jLeQAjZkIixbSLuIy+DuOlOx9/WRzG +an5mzO0kZNo7SuhaMbQF3Ee7BQybY94kfpGm/ZA1LG0zDkZe6chFV3xU/Ssn6iHB +1OLUYxGgag6helQmWnZkf6tRuanNDf4jSMBhQUoFwVQq7+WddcNoMrXso5Y2iD8V +7Gyecd1Tsux5xHdycgH7o29UnenBnAA/0b1pYJVLo0nd5M5n48xMaZQMFd28Wl/K +6gduSgwcD6HMo7NQURE+kmZukls7ZqBK/4YLFYo1d7m/OSqwL3S134Dbnugt8hHs +gF7eY4NuvfiexhxfABEBAAGJAjwEGAEIACYWIQTTViHVOhzGo0VnWNA2IOnTh+VW +ZgUCWuEwewIbDAUJB4YfgAAKCRA2IOnTh+VWZo+yD/9skuTQXpEmKGmQd7M34mB1 +uCA5xixheApgn/FTv6cuLWJbd3C6b8uN2MIlrLyfwTTRVBQ+RK1//22BsUCIOXEB +TVv0KhzTLHUGd2PSHtqXwOLgRcYyoO8wdkBjB0fyS7vN41iq32WSK3aHJUD5S0Dw +QDD5rgHtUEaiprllWFKz/a0KXFNGZaaZv+yLBCi7fY0hqT99h8kQyWHTzWsL9sDg +Dm1MLW8SY771ypD2X65gQp6nSU6dU7LS1WCWNOxSQoONUA7iFfjYGo44+sp0ZT2f +OjtA/fBOLcRqxMNx0mTw78iJuG5dT2xEfDTkBo6ONl8I/hEGthSku7AB+Uq/y5A+ +6893b8GvUPDe93UmOy0rggsyWrtoZglrkRCXygDr5cy0CEtRAm6jPVg/EjSaXeqF +l9+tWoh6/mwBZ3IMNk4Z4J4Yp1EkBzKp2gpQffy4HDcBq63SrXBIEUiqLvTXcmjH +AxAvY4dIYc6DDKmHC4i2wx+nM2ib8CRxIUTrkHICMdLilFEUF4+zimy9qy+59+2x +oiS1jBSx4QxyKk3C6N86Vp9VUh8f4vPnqQjOIyhVAJpA5BFERk51U8CfZtQemTxH +iwNve/B5HgEEc7eTuuJ9ASqIiiyCCD4AMjAjR2b8Oo6VCxoFiHWCgaCy+OHIP+/c +PSxdIAmV43ZrNIOxKzYOsA== +=w412 +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/build/docker/deb/gpg-keys/monacoin-releases.asc b/build/docker/deb/gpg-keys/monacoin-releases.asc index 0df9b356c5..35f5ea5c47 100644 --- a/build/docker/deb/gpg-keys/monacoin-releases.asc +++ b/build/docker/deb/gpg-keys/monacoin-releases.asc @@ -2,23 +2,71 @@ Version: SKS 1.1.6 Comment: Hostname: pgp.mit.edu -mQENBFX0IQUBCACqwvKod4QR3wpLXFPDDVupeSvIvs05NXIFrsWXdfqWqZql2TvLkNWp7Vgh -Iq2X5BO+NHa6y7w2OJHKIxMfgPw1wdlF96C+gy4LuZDcL9tXZtcKYRr9GlQwO81wrwqSlUgE -QEBnFM4di7gw//5u9jXBhHqa50GVXw95G+86CdMFhpdywQzcC1Z9c0NRqrvbAmLG4jyb5dIu -sEjRuhMndDUqsHnQt0jaR8FyuEhXtMjS6kECErksIER1iWq/9HhERmaqBHbIma9aHt0Xe+Kh -JffS6KrgRUT/wnN5e7tPyWHXbXt8tCVQyVFhgBG6DmA6pN8fz8Wn7cdZJe8ahdUEWTTTABEB -AAG0K21vbmFjb2lucHJvamVjdCA8bW9uYWNvaW5wcm9qZWN0QGdtYWlsLmNvbT6JATMEEAEI -AB0WIQRYnUM/PjP+pJFPs8Khh7o4XtTKGQUCWisAQgAKCRChh7o4XtTKGaj1B/wLsgMzs4TJ -XZDN7uZa8Xg3dSQ0oJcHfV/wjofGsCd8tljRT9vfaB6U/MogpfXYskzKMr77URo0dGDNvGri -fYn4bfk1NavY/XSYpdMtuxMpsYXbXhd2cKR3u5pqKrMCRtOqwumYMY76otZJiUYW9uAB9/pA -XYL7C2ZT8mAU9vkGEXeCGXREyXH586tqLyqt1DYSz9VP2Y4z7ERCWw7NpMOy15EXfDqaMJFt -zFMgNk+uRt7Vzy0XsA6/lTqSn/YUYoNrwlBugeqP4Etd13hHJZoaAjH3JXg+dNGsI22RfO65 -FmSaQA3FfsX2lBxq+l4netTBCn1TnO6V66PJxlRrHKUIiQE4BBMBAgAiBQJV9CEFAhsDBgsJ -CAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAKCRBFx97pvBhxqADzB/9TO242dEIes3sK+K89sbQC -Z8+hYRW7FudGFHvoY9OsSfjQjR/cqlVNPdgiWhg6wp96k/ajgCOWindQ1iosP1pkG7DMYr4e -K1iBY39ZD6PrlSGNv0MDU4Wo7koRsSGsXTbzrG6XtNxv2YyPGjER8s5GKn217KRQQiSSfUF/ -jK/tNnj4PJVCCyu29vHjghebQZWyfB3snFXs1It8/xoom8EQi6IfpVDlKEK8RxNEafXlEEaX -zEiquu2VvkY55O81Rbv3Gn7t9lQi9ug0TABkuA8RHDfbNpHKr57un39SUiX2m0qlZ1Fj03LI -zjU7IhcP+nqRkUbsEcqe/DJWzzvIfMpl -=A1/a +mQGNBGDFYeIBDADtI4t2GFPvJvqafCXrrCWucp+GIAH8TnmrssQ9NnvhVgjO4nLCX0LtPMDq +H4PX/q1BjPwUb8Pd0X1dqv0zpC2c3aWvmRyMftRIiMqHck69cBLkjuwxWgdCNIUkFhFMjKMr +YoNCL2nQbOiTx88FVH5B05p3vpSuzD+A+X0b24f4mpZb6czCwJiQ29MEwqexbnxfczHjoJYM +ctyWYHEbahj1QDZdQaoHd2S8qbfHWL9YhpLptbn6u/SWWOJ+J79wVsVQgf7cjsuwf8gzmed7 +gF8/33FtnOE/olu19sFvB81X1r5ajN3dHlx/V2nf5yfBxHfGXFmU9dOKJFuOzy+/ioG+Zlyb +frPMUcc/bLUFWIpbxsJKjC8jUHzWDbFqMSoTmSTpB2uP+ZQ8EI7xE2wqQQHx2CuU9FVDps2C +s1uimDiOcp5KAGElrI3V3bWF/jn03Bt/H4HebM+DfNIzKaHWOf9ZRgVcuxnRFAScd+Qx25go +YlUD0h8crzeZHR1OUSL6wJ8AEQEAAbQtQ3J5cHRjb2luIEp1bmtleSA8Y3J5cHRjb2luLmp1 +bmtleUBnbWFpbC5jb20+iQHUBBMBCgA+FiEEvjBARRD348H3+8c/mf5OrC85fEwFAmDFYeIC +GwMFCQPCZwAFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQmf5OrC85fEwuQgv/UrSVvLed +cxfqNVT5//WVfxW34VWvyAmqgXzJJEx59j5xhcHK51bsvrHJFBVW1YkZhlIBp34krZmBsJDY +6637a+Bm6kQaDZuUxS9qW5lWL4dDPVcpBtzdDfNDu0Xu/aj4L3k1/QEp8xG8Bn6GTEZ/AIu1 +jiYbm9f/wxpJL+uwKoYMdZJf74aom9GFG6cD9w4tzVuor5oKOL2cJaR/OFn+bqdhFCnb4r0I +8MzxsTCZTfAbpK9Dz5yiGZgfBvOQf5ev6QjB7bubmiO8C1RoKdqZJZv0jR5C3mAutF7NkW65 +Tw0RumJB1OIXiy4+kKjgDG1DQMWzRbpQ0cWOd1G43hBZoJQzbzYZoZ1WEcoWLJp2ZKhHJ6Wm +KWCKsUFxsSZ5+a+v+aDZRYqgseQO19Qbu3Q1t/+If2/si3PAitnEpAyeEIpVlkZkZ/LF++iP +65H/2ydR4His2fjkApdftErdewTvPPAQjzJ50NjmBSuJsrBxc+hWuQ2nwwtLp5OKLGzyGI61 +uQGNBGDFYeIBDACqgVc7yOhE4juXY9u8A0VlJVTxS/EDogRA5fJ4Pj9tfzBvvPRxUVLTIcuC +2398FiSfbIRmlxeaJoxal0mKxCH8a9+L2/Ggx3suMMHz/GdaznxtS3yNJGwpH7Y44h+0l6V0 +0A3IaW23akUAm9UTVK911pVbquA7VSyVPDvgSBHr5Ueg4mUtA7777kKaxGhVi6dCq+xHCGDR +bLKfwUelTeT+2GU9tySUW6nZZaN7KFnZLPzoWOFLYD6El7XNonxm2wCNyRuTQP8/9XcZhEnb +vG6JfoU8rgnvSURQj9BM7J+NnFOmzIYGWT4ZxzZnTtbrlMAQo+hdZZ/NhQKOISWJI3fUrWGC +WlygQhsYL67VwVeNKiQlH5wvuiwGxv8nfUBmYqvS1GAY8196dE61Brill7MpD1BfID6TQGFa +WQJwTLdI35ENWd6hryR+HZ4ZEN+t3dG9+VVclzPC6dFCgN4tyMlIMMkXHCOXFLSRSZsRBUfX +p8lDs1qvpsP8sPuUD04ODccAEQEAAYkBvAQYAQoAJhYhBL4wQEUQ9+PB9/vHP5n+TqwvOXxM +BQJgxWHiAhsMBQkDwmcAAAoJEJn+TqwvOXxMu98L/0sotFNnRbjwAurv6M7zgfuONoyD0uWx +OI2/GoUi6ZSgs0EnhXAvKbjoXLRCm6t/XRTOTNkkPsJR8ti4ImOJpRHPSWaZXs7dpm0vrMLe +wig2Dyvnq1kP5BugTF0hvejA2W5cIrJbABb/EWwaRVqf9dgTN71DHmVYyjuWVh1pm+v1bo4H +vULg2Dst9bY2KsUSwNhK7BGB3fv22vjDKRcAB5yeOT6GAN2vWL3wVlSdBWKEV6KkFMrE6Kk2 +BYNRkz2WoyeFMJVsZsmtQVVYpzrQWSgEgIMEnXM4hy58bfTz5pWX8XUUjF7I/A0RxiCdIHq7 +gmFBNA89eVn3Zko0C5zcPM5Iy5pk4fWtcR4+O2UHQ8k68ksyKvy5NnK6i0u63MKdzthSneqx +IVWq8bCtMO8RW09EMfJCkO8Nbe1msPO+w6SmENXYbyxvWHMNE3/TqhfntC6GfdWUWxn9xysr +fLrBOxh9LbfhzTiaK3tTVTzugCH0UHkLPhJ37fGW7F9Ol2uDE7kErgRgxWPmEQwAhOU2RcMD +nekZ5IEPqZ8hsjaRCzLqVFRuV4Xb8LEvjmBc+HgeZYr9TRdKSKVNdynzMMZUSrtnkLGvErjr +T/nHPDJ28YtR0N41f8s4MVAF0Fshn224wiVtc79tM2lmVBLpPQMbxKs9UHHzQqa2ez7AK5XF +GmQ8QcZY3qffjTL/ZgO/QexzMEimYA6aaV0Zzsx7YOg3Yq/4jtU6TSLonr64o7ulUx9L3GIk +HlJej7Pas4jgenM3W5T75OVt1sQpgA7BIq1FGd/3iNQ/NrQU0VXr7GgHI8nhqVpgted8k67Q +ygDJ6Py/fBwl/sDQaWxY8MR/vjlt/a5+BiBjcCmgvDX8F2lx+VEHW5Dt7SGF/mVtN0dtBnu2 +FD9nzTOo7ebydde81wSabr52PRBZAffDQrUjyTB2VdZjZ/Jc5vR7Ub6SfCv6YGyjcY5dwSZk +C5298SXXIvboDwQgacGVhyBlpzLlUJiABL+9A3F6SH0z/IyQSecYeVR3OtKcitOtA6XfhOfL +AQDlpkD08jOzugLYvgYZ1jxtDZHwdQgbEAzK3fSHMXltqwv/ZNOB54ht/JucLxIUzM1vpZzQ +8UBU0huHni0cv9bm0QoCeKei9NuHLctA/uugphApZRsydaGsRu6iZYQT8WWAt/ZkyVhohb3y +ovhfhkbgHrQ68hWSB79JFV5Gtr6yvKx6MerJbZoIn+KqR0Gb589UmVvOXMj5k9xzrIgTmXuL +vXibxZmNTqWTTvQLlZ83AZISStnweaPxxzx3UFQ6nffDpgFwUcLy+v/u+jIMAFQhkG7XrPvx +u19qoGmF/cn82rkuxq6pmg7L+LJPdCFwxoQtIj6KASw73IeaUa98zrCVtRsHE8VTTOHZYL/D +IX8dADWaMAgGu0gEHZ6iegV8KMimXeVvSpGpUOc26WHwpH+Z+/leR4yrUQ55Ld4hyzj47aqy +ASkZ08A+MrBV5BseUdJxj6VqAlmBNpjcMoo7OzvbYwPLNam9d4KAFFYggxBcbkQl3wnO5ekG +wkYb2YuqNwiqkxgwFAiGggz3loIMxlY8RSHBpsZnvxUoqXgFNkzisRG4C/99fMaXnMB5C7/A +3GjzKBGd3IJGiGrRCZxnyWyMz937CTSaqdl3X4Q4dWy/gmpDPOxk5aShSbuTfZszZeqE5k7D +B3DAgni+YoB+jV13bixABCTwZwS8jhVKUWRzFq6J17df8wvQybyV0Bh1tRdoboLkw7gY6ANI +0Iwo2Ep4NT+vzEV+xMShbJLFAJ5ZmA9AJKFQJYEBSlnSYNVR72GguprXGv1h/xxPGLMGZkgI +CDDVNXJncfdlHjGwEwmfNMGcVUB81CNYd2P4g1truybXQVQOLnB/MC6jtPEjn9GXHsfmchWf +5vax4rS/HwfqNRm0D603GMBhWw9xnuqiGuyQwo9txeiS20w45TXaZ0k0kq2qawm+QQvQ4KhH +88wrwU1375Szp5uDDv+zQKMpdqFZ2LsWRnZd+YZPrgXpH5n8USuTjLSAb7aoAjiIoNl+av+8 +LK8URbfJRHpdBr8Xk53JlNw2IlHZ6qr3PP8sekyZssuS3jpuDSUajzuE5crLqiwidJuJAjME +GAEKACYWIQS+MEBFEPfjwff7xz+Z/k6sLzl8TAUCYMVj5gIbAgUJA8JnAACBCRCZ/k6sLzl8 +THYgBBkRCAAdFiEEnV5EHQAS4ZN5nLvt4GnBsxGdKx4FAmDFY+YACgkQ4GnBsxGdKx591QD8 +D48ICJPEMaNNDrqa/3AmfJZ+vdjvnZ9+bbvGEC/2BhABAMj6aZhj/ciczGVWMLb131EfT0Kj +VGznspZKyYN9YLBkAmIMAJGVD82kClFa5FKQ1oUksbsHh8FDLh0iAXWo6Jb/ITtV/6ZMfbUD +ZhzboRPY/cLPDUaXiC7ES7nzTFUnZRh2/hVGHCX7w36H1WXxvVcDB2gwL5LXKhIt89c9lVbu +DOerVyKU2BzzQz03GP+v7iBP9Id+ONOEDMM967eXv63JGfXfLQGyrWygnOM72lGXiK5Zdtg4 +YMZFtPY+dhbb8Mv0sAbCEuJIABY/IlApL+ZKYbwewrJ8lz1VzahExiDT5n1Mh1XLH0J0NOs4 +GfuZFCtp3dRK7zlYOZCFmggYhKtYdzUbdgJbYNaCFL9mvx3bJkdiQHAs1x2UEvtU5030HwxC +gDc7dsbG7f/NVNivinv8lmvDdAr+vBGtz1XQtAYIFTdH7hbLg+obJ1tynTGzy6PenkC4f0FV +FylvMqO9+qR4qOMrd/V07hDZzSK/7vdrcyRIg78N7txvSc5c10zJ67tL7l9xvvMa6pmFo6uI +S+NBgDdgpO4dNoun674hk0MNJlXy3A== +=W85i -----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 31d4ad2ccd..068284d9f1 100644 --- a/build/templates/backend/config/bitcoin.conf +++ b/build/templates/backend/config/bitcoin.conf @@ -1,7 +1,7 @@ {{define "main" -}} daemon=1 server=1 -{{if .Backend.Mainnet}}{{else}}testnet=1{{end}} +{{if .Backend.Mainnet}}mainnet=1{{else}}testnet=1{{end}} nolisten=1 txindex=1 disablewallet=1 @@ -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 645f252260..983c5c002d 100644 --- a/build/templates/backend/config/bitcoin_like.conf +++ b/build/templates/backend/config/bitcoin_like.conf @@ -1,10 +1,12 @@ {{define "main" -}} daemon=1 server=1 -{{if .Backend.Mainnet}}{{else}}testnet=1{{end}} +{{if .Backend.Mainnet}}mainnet=1{{else}}testnet=1{{end}} 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 new file mode 100644 index 0000000000..15eb979b82 --- /dev/null +++ b/build/templates/backend/config/bitcoin_regtest.conf @@ -0,0 +1,37 @@ +{{define "main" -}} +daemon=0 +server=1 +nolisten=1 +txindex=1 +disablewallet=0 + +zmqpubhashtx={{template "IPC.MessageQueueBindingTemplate" .}} +zmqpubhashblock={{template "IPC.MessageQueueBindingTemplate" .}} + +rpcworkqueue=1100 +maxmempool=2000 +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}} + +regtest=1 + +{{if .Backend.Mainnet}}[main]{{else}}[regtest]{{end}} +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 new file mode 100644 index 0000000000..11b639664a --- /dev/null +++ b/build/templates/backend/config/bitcoin_signet.conf @@ -0,0 +1,36 @@ +{{define "main" -}} +daemon=1 +server=1 +{{if .Backend.Mainnet}}mainnet=1{{else}}signet=1{{end}} +nolisten=1 +txindex=1 +disablewallet=1 + +zmqpubhashtx={{template "IPC.MessageQueueBindingTemplate" .}} +zmqpubhashblock={{template "IPC.MessageQueueBindingTemplate" .}} + +rpcworkqueue=1100 +maxmempool=2000 +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}}[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/syscoin.conf b/build/templates/backend/config/syscoin.conf index f60a165fa4..b625de83fe 100644 --- a/build/templates/backend/config/syscoin.conf +++ b/build/templates/backend/config/syscoin.conf @@ -10,7 +10,8 @@ rpcuser={{.IPC.RPCUser}} rpcpassword={{.IPC.RPCPass}} zmqpubhashtx={{template "IPC.MessageQueueBindingTemplate" .}} zmqpubhashblock={{template "IPC.MessageQueueBindingTemplate" .}} -zmqpubnevm= +# SYSCOIN: leave zmqpubnevm unset so syscoind uses its main/test default +# (tcp://127.0.0.1:1111) and starts managed sysgeth with state bootstrap. rpcworkqueue=1100 maxmempool=3000 @@ -25,6 +26,11 @@ rpcport={{.Ports.BackendRPC}} {{- range $index, $node := $value}} addnode={{$node}} {{- end}} +{{- else if eq $name "gethcommandline"}} +{{/* SYSCOIN: syscoind accepts one gethcommandline value per managed sysgeth arg. */}} +{{- range $index, $arg := $value}} +gethcommandline={{$arg}} +{{- end}} {{- else}} {{$name}}={{$value}} {{- end}} 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/config/zcash_testnet.conf b/build/templates/backend/config/zcash_testnet.conf new file mode 100644 index 0000000000..66e1167e36 --- /dev/null +++ b/build/templates/backend/config/zcash_testnet.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:18233" +max_connections_per_ip = 1 +network = "Testnet" +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}} 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 4d2019bd69..265c2dcb42 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}}, @@ -21,12 +24,14 @@ {{end}}{{if .Blockbook.BlockChain.XPubMagicSegwitNative}} "xpub_magic_segwit_native": {{.Blockbook.BlockChain.XPubMagicSegwitNative}}, {{end}}{{if .Blockbook.BlockChain.Slip44}} "slip44": {{.Blockbook.BlockChain.Slip44}}, {{end}} +{{/* SYSCOIN: optional NEVM endpoints for SPT metadata enrichment. */}} {{if .Blockbook.BlockChain.Web3RPCURL}} "web3_rpc_url": "{{.Blockbook.BlockChain.Web3RPCURL}}", {{end}}{{if .Blockbook.BlockChain.Web3RPCURLBackup}} "web3_rpc_url_backup": "{{.Blockbook.BlockChain.Web3RPCURLBackup}}", {{end}}{{if .Blockbook.BlockChain.Web3Explorer}} "web3_explorer_url": "{{.Blockbook.BlockChain.Web3Explorer}}", {{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/install b/build/templates/blockbook/debian/install index d2b8422c5f..d797187cdc 100644 --- a/build/templates/blockbook/debian/install +++ b/build/templates/blockbook/debian/install @@ -2,6 +2,7 @@ blockbook {{.Env.BlockbookInstallPath}}/{{.Coin.Alias}}/bin cert {{.Env.BlockbookInstallPath}}/{{.Coin.Alias}} static {{.Env.BlockbookInstallPath}}/{{.Coin.Alias}} +openapi.yaml {{.Env.BlockbookInstallPath}}/{{.Coin.Alias}} blockchaincfg.json {{.Env.BlockbookInstallPath}}/{{.Coin.Alias}}/config logrotate.sh {{.Env.BlockbookInstallPath}}/{{.Coin.Alias}}/bin ldb {{.Env.BlockbookInstallPath}}/{{.Coin.Alias}}/bin diff --git a/build/templates/blockbook/debian/service b/build/templates/blockbook/debian/service index e354121598..bd97e30baa 100644 --- a/build/templates/blockbook/debian/service +++ b/build/templates/blockbook/debian/service @@ -2,10 +2,19 @@ [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}} +# Optional operator-supplied runtime environment (API keys such as INFURA_API_KEY +# and per-coin tunables; see docs/env.md). The leading '-' makes the file optional, +# so public/community installs and hosts that still rely on systemd +# DefaultEnvironment are unaffected when it is absent. The service manager (root) +# reads it at start, so it need not be readable by the blockbook user, and a +# unit-level value here overrides a DefaultEnvironment value for the same key. +EnvironmentFile=-/etc/blockbook/blockbook.env +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{{if .Env.BlockbookPprofBinding}} -prof={{.Env.BlockbookPprofBinding}}{{end}} {{.Blockbook.AdditionalParams}} User={{.Blockbook.SystemUser}} Type=simple Restart=on-failure diff --git a/build/templates/generate.go b/build/templates/generate.go index 3c706cda28..3f7744ef4e 100644 --- a/build/templates/generate.go +++ b/build/templates/generate.go @@ -6,7 +6,7 @@ import ( "path/filepath" "strings" - "github.com/syscoin/blockbook/build/tools" + "github.com/trezor/blockbook/build/tools" ) const ( diff --git a/build/tools/build_var_prefixes_test.go b/build/tools/build_var_prefixes_test.go new file mode 100644 index 0000000000..1b26666ec6 --- /dev/null +++ b/build/tools/build_var_prefixes_test.go @@ -0,0 +1,62 @@ +package build + +import ( + "bufio" + "os" + "strings" + "testing" +) + +// canonicalPrefixFile is the single source of truth shared with the Makefile and +// .github/actions/export-env-vars/action.yml. Path is relative to this package +// directory (build/tools). +const canonicalPrefixFile = "../bb-build-var-prefixes.txt" + +func loadCanonicalPrefixes(t *testing.T) map[string]bool { + t.Helper() + f, err := os.Open(canonicalPrefixFile) + if err != nil { + t.Fatalf("open %s: %v", canonicalPrefixFile, err) + } + defer f.Close() + + set := map[string]bool{} + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + set[line] = true + } + if err := scanner.Err(); err != nil { + t.Fatalf("read %s: %v", canonicalPrefixFile, err) + } + return set +} + +// TestCanonicalPrefixesCoverTemplateConsumers guards against drift between the +// shared bb-build-var-prefixes.txt and the build-time variable prefixes that +// templates.go relies on. If a prefix used here is removed or renamed in the +// canonical file (and thus dropped from the Makefile/action forwarding), this +// test fails instead of the breakage surfacing only at build time. +func TestCanonicalPrefixesCoverTemplateConsumers(t *testing.T) { + canonical := loadCanonicalPrefixes(t) + + usedByTemplates := []string{ + devRPCURLHTTPPrefix, + devRPCURLWSPrefix, + devMQURLPrefix, + prodRPCURLHTTPPrefix, + prodRPCURLWSPrefix, + prodMQURLPrefix, + "BB_RPC_BIND_HOST_", // inline literal in LoadConfig + "BB_RPC_ALLOW_IP_", // inline literal in LoadConfig + } + + for _, prefix := range usedByTemplates { + if !canonical[prefix] { + t.Errorf("prefix %q used by templates.go is missing from %s", prefix, canonicalPrefixFile) + } + } +} diff --git a/build/tools/templates.go b/build/tools/templates.go index 5ec77cfbae..dde76d6068 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,17 +49,23 @@ 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"` BackendMessageQueue int `json:"backend_message_queue"` + BackendP2P int `json:"backend_p2p"` + BackendHttp int `json:"backend_http"` + BackendAuthRpc int `json:"backend_authrpc"` BlockbookInternal int `json:"blockbook_internal"` BlockbookPublic int `json:"blockbook_public"` } `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"` @@ -68,21 +80,22 @@ 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"` - // SYSCOIN - Web3RPCURL string `json:"web3_rpc_url,omitempty"` - Web3RPCURLBackup string `json:"web3_rpc_url_backup,omitempty"` - Web3Explorer string `json:"web3_explorer_url,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"` + // SYSCOIN: NEVM RPC endpoints used for SPT metadata enrichment. + Web3RPCURL string `json:"web3_rpc_url,omitempty"` + Web3RPCURLBackup string `json:"web3_rpc_url_backup,omitempty"` + Web3Explorer string `json:"web3_explorer_url,omitempty"` + AdditionalParams map[string]json.RawMessage `json:"additional_params"` } `json:"block_chain"` } `json:"blockbook"` @@ -92,15 +105,32 @@ type Config struct { PackageMaintainerEmail string `json:"package_maintainer_email"` } `json:"meta"` Env struct { - Version string `json:"version"` - BackendInstallPath string `json:"backend_install_path"` - BackendDataPath string `json:"backend_data_path"` - BlockbookInstallPath string `json:"blockbook_install_path"` - BlockbookDataPath string `json:"blockbook_data_path"` - Architecture string `json:"architecture"` + Version string `json:"version"` + BackendInstallPath string `json:"backend_install_path"` + BackendDataPath string `json:"backend_data_path"` + 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. + BlockbookPprofBinding string `json:"-"` // Derived for dev builds so deployed Blockbooks expose pprof on a per-coin port. + 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_" + devBlockbookPprofPortOffset = 20000 +) + func jsonToString(msg json.RawMessage) (string, error) { d, err := msg.MarshalJSON() if err != nil { @@ -120,10 +150,187 @@ 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 blockbookPprofBindingForBuildEnv(config *Config, buildEnv string) string { + if buildEnv != buildEnvDev || isEmpty(config, "blockbook") || config.Ports.BlockbookInternal <= 0 { + return "" + } + return fmt.Sprintf(":%d", config.Ports.BlockbookInternal+devBlockbookPprofPortOffset) +} + +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, @@ -161,6 +368,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 +399,33 @@ 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 + config.Env.BlockbookPprofBinding = blockbookPprofBindingForBuildEnv(config, buildEnv) + + 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 @@ -203,11 +446,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 } @@ -222,6 +471,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() @@ -281,11 +582,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 } } @@ -355,3 +660,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..cdc6bdce8d --- /dev/null +++ b/build/tools/templates_test.go @@ -0,0 +1,426 @@ +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 TestLoadConfigSetsBlockbookPprofBindingForDevBuilds(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + + tests := []struct { + name string + buildEnv string + }{ + {name: "default-dev"}, + {name: "explicit-dev", buildEnv: buildEnvDev}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withTemporarilyUnsetEnv(t, buildEnvVar) + if tt.buildEnv != "" { + t.Setenv(buildEnvVar, tt.buildEnv) + } + + config, err := LoadConfig(configsDir, "ethereum") + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if config.Env.BlockbookPprofBinding != ":29036" { + t.Fatalf("BlockbookPprofBinding = %q, want %q", config.Env.BlockbookPprofBinding, ":29036") + } + + templ := config.ParseTemplate() + templ = template.Must(templ.ParseFiles(filepath.Join("..", "templates", "blockbook", "debian", "service"))) + + var service bytes.Buffer + if err := templ.ExecuteTemplate(&service, "main", config); err != nil { + t.Fatalf("ExecuteTemplate(blockbook service) error = %v", err) + } + if rendered := service.String(); !strings.Contains(rendered, " -prof=:29036 ") { + t.Fatalf("expected pprof flag in rendered service:\n%s", rendered) + } + }) + } +} + +func TestLoadConfigOmitsBlockbookPprofBindingForProdBuild(t *testing.T) { + configsDir := filepath.Clean(filepath.Join("..", "..", "configs")) + + withTemporarilyUnsetEnv(t, buildEnvVar) + t.Setenv(buildEnvVar, buildEnvProd) + + config, err := LoadConfig(configsDir, "ethereum") + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if config.Env.BlockbookPprofBinding != "" { + t.Fatalf("BlockbookPprofBinding = %q, want empty", config.Env.BlockbookPprofBinding) + } + + templ := config.ParseTemplate() + templ = template.Must(templ.ParseFiles(filepath.Join("..", "templates", "blockbook", "debian", "service"))) + + var service bytes.Buffer + if err := templ.ExecuteTemplate(&service, "main", config); err != nil { + t.Fatalf("ExecuteTemplate(blockbook service) error = %v", err) + } + if rendered := service.String(); strings.Contains(rendered, "-prof=") { + t.Fatalf("did not expect pprof flag in rendered service:\n%s", rendered) + } +} + +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 af3855c491..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 ( @@ -13,7 +13,7 @@ import ( "strconv" "strings" - build "github.com/syscoin/blockbook/build/tools" + build "github.com/trezor/blockbook/build/tools" ) const ( diff --git a/build/tools/typescriptify/typescriptify.go b/build/tools/typescriptify/typescriptify.go new file mode 100644 index 0000000000..076b06cd2e --- /dev/null +++ b/build/tools/typescriptify/typescriptify.go @@ -0,0 +1,78 @@ +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(bchain.TronAccountExtraData{}) + 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 new file mode 100644 index 0000000000..65859e9657 --- /dev/null +++ b/common/currencyrateticker.go @@ -0,0 +1,114 @@ +package common + +import ( + "strings" + "time" +) + +// 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,omitempty"` // rates of the tokens (identified by the address of the contract) against the base currency +} + +var ( + // TickerRecalculateTokenRate signals if it is necessary to recalculate token rate to base rate + // this happens when token rates are downloaded in TokenVsCurrency different from the base currency + TickerRecalculateTokenRate bool + // TickerTokenVsCurrency is the currency in which the token rates are downloaded + 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.findTokenRate(token) + if !found { + return 0, false + } + if TickerRecalculateTokenRate { + vsRate, found := t.Rates[TickerTokenVsCurrency] + if !found || vsRate == 0 { + return 0, false + } + rate = rate / vsRate + } + return rate, found + } + return 0, false +} + +// Convert converts value in base currency to toCurrency +func (t *CurrencyRatesTicker) Convert(baseValue float64, toCurrency string) float64 { + rate, found := t.Rates[toCurrency] + if !found { + return 0 + } + return baseValue * float64(rate) +} + +// ConvertTokenToBase converts token value to base currency +func (t *CurrencyRatesTicker) ConvertTokenToBase(value float64, token string) float64 { + rate, found := t.GetTokenRate(token) + if found { + return value * float64(rate) + } + return 0 +} + +// 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 { + return t.Convert(baseValue, toCurrency) + } + return 0 +} + +// TokenRateInCurrency return token rate in toCurrency currency +func (t *CurrencyRatesTicker) TokenRateInCurrency(token string, toCurrency string) float32 { + rate, found := t.GetTokenRate(token) + if found { + baseRate, found := t.Rates[toCurrency] + if found { + return baseRate * rate + } + } + return 0 +} + +// IsSuitableTicker checks if the ticker can provide data for given vsCurrency or token +func IsSuitableTicker(ticker *CurrencyRatesTicker, vsCurrency string, token string) bool { + if vsCurrency != "" { + if ticker.Rates == nil { + return false + } + if _, found := ticker.Rates[vsCurrency]; !found { + return false + } + } + if token != "" { + if ticker.TokenRates == nil { + return false + } + if _, found := ticker.findTokenRate(token); !found { + return false + } + } + return true +} diff --git a/common/currencyrateticker_test.go b/common/currencyrateticker_test.go new file mode 100644 index 0000000000..076dbaa284 --- /dev/null +++ b/common/currencyrateticker_test.go @@ -0,0 +1,102 @@ +package common + +import ( + "testing" +) + +func TestCurrencyRatesTicker_ConvertToken(t *testing.T) { + ticker := &CurrencyRatesTicker{ + Rates: map[string]float32{ + "usd": 2129.987654321, + "eur": 1332.12345678, + }, + TokenRates: map[string]float32{ + "0x82df128257a7d7556262e1ab7f1f639d9775b85e": 0.4092341123, + "0x6b175474e89094c44da98b954eedeac495271d0f": 12.32323232323232, + "0xdac17f958d2ee523a2206206994597c13d831ec7": 1332421341235.51234, + }, + } + tests := []struct { + name string + value float64 + token string + toCurrency string + want float64 + }{ + { + name: "usd 0x82df128257a7d7556262e1ab7f1f639d9775b85e", + value: 10, + token: "0x82df128257a7d7556262e1ab7f1f639d9775b85e", + toCurrency: "usd", + want: 8716.635514874506, + }, + { + name: "eur 0xdac17f958d2ee523a2206206994597c13d831ec7", + value: 23.123, + token: "0xdac17f958d2ee523a2206206994597c13d831ec7", + toCurrency: "eur", + want: 4.104216071804417e+16, + }, + { + name: "eur 0xdac17f958d2ee523a2206206994597c13d831ec8", + value: 23.123, + token: "0xdac17f958d2ee523a2206206994597c13d831ec8", + toCurrency: "eur", + want: 0, + }, + { + name: "eur 0xdac17f958d2ee523a2206206994597c13d831ec7", + value: 23.123, + token: "0xdac17f958d2ee523a2206206994597c13d831ec7", + toCurrency: "czk", + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ticker.ConvertToken(tt.value, tt.token, tt.toCurrency); got != tt.want { + t.Errorf("CurrencyRatesTicker.ConvertToken() = %v, want %v", got, tt.want) + } + }) + } +} + +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 bf8a46b5a3..1964d287c4 100644 --- a/common/internalstate.go +++ b/common/internalstate.go @@ -2,9 +2,13 @@ package common import ( "encoding/json" + "slices" "sort" "sync" + "sync/atomic" "time" + + "github.com/golang/glog" ) const ( @@ -16,70 +20,122 @@ const ( DbStateInconsistent ) +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"` - 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"` - - InitialSync bool `json:"initialSync"` - IsSynchronized bool `json:"isSynchronized"` - BestHeight uint32 `json:"bestHeight"` - LastSync time.Time `json:"lastSync"` - BlockTimes []uint32 `json:"-"` - - IsMempoolSynchronized bool `json:"isMempoolSynchronized"` - MempoolSize int `json:"mempoolSize"` - LastMempoolSync time.Time `json:"lastMempoolSync"` - - DbColumns []InternalStateColumn `json:"dbColumns"` - - UtxoChecked bool `json:"utxoChecked"` + SyncMode bool `json:"syncMode" ts_doc:"Flag indicating if the node is in sync mode."` + + 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" 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" ts_doc:"List of database column statistics."` + + 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."` + + EnableSubNewTx bool `json:"-" ts_doc:"Internal flag controlling subscription to new transactions (not exposed)."` + + BackendInfo BackendInfo `json:"-" ts_doc:"Information about the connected blockchain backend (not exposed in JSON)."` + + BackendTipLastAdvance time.Time `json:"-" ts_doc:"Wall-clock time when BackendInfo.Blocks was last observed to advance (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)."` + + // BalanceHistoryMaxTxsWS / BalanceHistoryMaxTxsREST cap how many transactions a + // single balance-history request (address or xpub) may aggregate; each costs a DB + // read, so this bounds a cheap-to-send DoS and errors past the cap (0 = unlimited). + // WS is generous (Trezor Suite's full-history graph); REST is tighter (open surface). + BalanceHistoryMaxTxsWS int `json:"-" ts_doc:"Max transactions aggregated per WS balance-history request, 0 = unlimited (not exposed)."` + BalanceHistoryMaxTxsREST int `json:"-" ts_doc:"Max transactions aggregated per REST balance-history request, 0 = unlimited (not exposed)."` + + // websocket IP blocklist: keys (IPv4 address or full IPv6 /128) blocked from + // opening new connections after flooding a single connection past the message-rate + // limit. Keyed on the /128 (not the limiter's /64) so a block cannot take out a + // shared /64. Guarded by its own mutex (consulted on every connection attempt). + wsBlockMux sync.Mutex + wsBlockedIPs map[string]*WsBlockedIP +} - BackendInfo BackendInfo `json:"-"` +// WsBlockedIP records a websocket client key (IPv4 address or full IPv6 /128) +// that is temporarily blocked from opening new websocket connections. +type WsBlockedIP struct { + Key string `ts_doc:"Blocked client key: an IPv4 address or a full IPv6 /128 address."` + BlockedAt time.Time `ts_doc:"Time the key was first blocked in the current block."` + Until time.Time `ts_doc:"Time the block expires."` + Breaches int `ts_doc:"How many times this key tripped the per-connection message rate limit."` + Rejected int `ts_doc:"How many new connections were rejected while the key was blocked."` } // StartedSync signals start of synchronization func (is *InternalState) StartedSync() { is.mux.Lock() defer is.mux.Unlock() + is.StartSync = time.Now().UTC() is.IsSynchronized = false } @@ -89,7 +145,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 @@ -97,7 +153,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 @@ -108,10 +164,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 @@ -173,7 +229,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 @@ -197,11 +253,45 @@ func (is *InternalState) GetBlockTime(height uint32) uint32 { return 0 } -// AppendBlockTime appends block time to BlockTimes -func (is *InternalState) AppendBlockTime(time uint32) { +// GetLastBlockTime returns time of the last block +func (is *InternalState) GetLastBlockTime() uint32 { is.mux.Lock() defer is.mux.Unlock() - is.BlockTimes = append(is.BlockTimes, time) + if len(is.BlockTimes) > 0 { + return is.BlockTimes[len(is.BlockTimes)-1] + } + return 0 +} + +// SetBlockTimes initializes BlockTimes array, returns AvgBlockPeriod +func (is *InternalState) SetBlockTimes(blockTimes []uint32) uint32 { + is.mux.Lock() + defer is.mux.Unlock() + 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 +} + +// 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() + 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 } // RemoveLastBlockTimes removes last times from BlockTimes @@ -212,6 +302,7 @@ func (is *InternalState) RemoveLastBlockTimes(count int) { count = len(is.BlockTimes) } is.BlockTimes = is.BlockTimes[:len(is.BlockTimes)-count] + is.computeAvgBlockPeriod() } // GetBlockHeightOfTime returns block height of the first block with time greater or equal to the given time or MaxUint32 if no such block @@ -235,10 +326,43 @@ func (is *InternalState) GetBlockHeightOfTime(time uint32) uint32 { return uint32(height) } -// SetBackendInfo sets new BackendInfo +const avgBlockPeriodSample = 100 + +// Avg100BlocksPeriod returns average period of the last 100 blocks in seconds +func (is *InternalState) GetAvgBlockPeriod() uint32 { + is.mux.Lock() + defer is.mux.Unlock() + return is.AvgBlockPeriod +} + +// computeAvgBlockPeriod returns computes average of the last 100 blocks in seconds +func (is *InternalState) computeAvgBlockPeriod() { + last := len(is.BlockTimes) - 1 + first := last - avgBlockPeriodSample - 1 + if first < 0 { + return + } + 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 and records the time when Blocks advances. +// On the first observation the advance time is seeded to now so the +// derived tip-age metric reads a meaningful value instead of "since epoch." func (is *InternalState) SetBackendInfo(bi *BackendInfo) { is.mux.Lock() defer is.mux.Unlock() + if bi.Blocks > is.BackendInfo.Blocks || is.BackendTipLastAdvance.IsZero() { + is.BackendTipLastAdvance = time.Now() + } is.BackendInfo = *bi } @@ -249,6 +373,19 @@ func (is *InternalState) GetBackendInfo() BackendInfo { return is.BackendInfo } +// GetBackendTipLastAdvance returns the wall-clock time when the backend's +// Blocks height was last observed to advance. BackendTipLastAdvance is not +// persisted, so on startup (before the first SetBackendInfo) it is zero; seed +// it to now on first read so tip-age metrics don't report a bogus huge age. +func (is *InternalState) GetBackendTipLastAdvance() time.Time { + is.mux.Lock() + defer is.mux.Unlock() + if is.BackendTipLastAdvance.IsZero() { + is.BackendTipLastAdvance = time.Now() + } + return is.BackendTipLastAdvance +} + // Pack marshals internal state to json func (is *InternalState) Pack() ([]byte, error) { is.mux.Lock() @@ -265,3 +402,118 @@ func UnpackInternalState(buf []byte) (*InternalState, error) { } return &is, nil } + +// SetInShutdown sets the internal state to in shutdown state +func SetInShutdown() { + atomic.StoreInt32(&inShutdown, 1) +} + +// IsInShutdown returns true if in application shutdown state +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) +} + +// WsLimitExceedingIPsSnapshot returns a copy of the limit-exceeding counters. +// The map is mutated under is.mux by AddWsLimitExceedingIP, so readers (the +// admin page) must not range over it directly. +func (is *InternalState) WsLimitExceedingIPsSnapshot() map[string]int { + is.mux.Lock() + defer is.mux.Unlock() + out := make(map[string]int, len(is.WsLimitExceedingIPs)) + for k, v := range is.WsLimitExceedingIPs { + out[k] = v + } + return out +} + +// BlockWsIP flags a websocket client key (an IPv4 address or full IPv6 /128) as +// blocked until the given time. If the key is already blocked the block is +// extended to the later of the two expirations and the breach counter is +// incremented; otherwise a new entry is created. now is passed in so callers can +// inject a clock in tests. +func (is *InternalState) BlockWsIP(key string, until, now time.Time) { + is.wsBlockMux.Lock() + defer is.wsBlockMux.Unlock() + if is.wsBlockedIPs == nil { + is.wsBlockedIPs = make(map[string]*WsBlockedIP) + } + e := is.wsBlockedIPs[key] + if e == nil || !now.Before(e.Until) { + // new block, or the previous one had already expired: reset the window + e = &WsBlockedIP{Key: key, BlockedAt: now} + is.wsBlockedIPs[key] = e + } + e.Breaches++ + if until.After(e.Until) { + e.Until = until + } +} + +// IsWsIPBlocked reports whether the key is currently blocked. When blocked it +// records a rejected connection so the admin page can show how much traffic the +// block is shedding, and returns the updated count so the caller can throttle +// per-attempt logging. Expired entries are treated as not blocked (the periodic +// SweepWsBlockedIPs removes them). +func (is *InternalState) IsWsIPBlocked(key string, now time.Time) (bool, int) { + is.wsBlockMux.Lock() + defer is.wsBlockMux.Unlock() + e := is.wsBlockedIPs[key] + if e == nil || now.Before(e.BlockedAt) { + return false, 0 + } + if !now.Before(e.Until) { + return false, 0 + } + e.Rejected++ + return true, e.Rejected +} + +// SweepWsBlockedIPs removes expired entries and returns the number of keys that +// remain blocked, for the websocket_blocked_ips gauge. +func (is *InternalState) SweepWsBlockedIPs(now time.Time) int { + is.wsBlockMux.Lock() + defer is.wsBlockMux.Unlock() + for key, e := range is.wsBlockedIPs { + if !now.Before(e.Until) { + delete(is.wsBlockedIPs, key) + } + } + return len(is.wsBlockedIPs) +} + +// WsBlockedIPsSnapshot returns a copy of the currently blocked entries, newest +// expiry first, skipping any that have already expired. +func (is *InternalState) WsBlockedIPsSnapshot(now time.Time) []WsBlockedIP { + is.wsBlockMux.Lock() + defer is.wsBlockMux.Unlock() + out := make([]WsBlockedIP, 0, len(is.wsBlockedIPs)) + for _, e := range is.wsBlockedIPs { + if !now.Before(e.Until) { + continue + } + out = append(out, *e) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Until.After(out[j].Until) + }) + return out +} + +// ResetWsBlockedIPs clears the websocket IP blocklist. +func (is *InternalState) ResetWsBlockedIPs() { + is.wsBlockMux.Lock() + defer is.wsBlockMux.Unlock() + is.wsBlockedIPs = make(map[string]*WsBlockedIP) +} diff --git a/common/internalstate_test.go b/common/internalstate_test.go new file mode 100644 index 0000000000..a139ab25ef --- /dev/null +++ b/common/internalstate_test.go @@ -0,0 +1,140 @@ +//go:build unittest + +package common + +import ( + "testing" + "time" +) + +func TestWsIPBlocklistBlockAndExpire(t *testing.T) { + is := &InternalState{} + now := time.Unix(1_700_000_000, 0) + key := "192.0.2.10" + + if blocked, _ := is.IsWsIPBlocked(key, now); blocked { + t.Fatal("key should not be blocked before any Block call") + } + + is.BlockWsIP(key, now.Add(time.Hour), now) + if blocked, _ := is.IsWsIPBlocked(key, now); !blocked { + t.Fatal("key should be blocked immediately after Block") + } + if blocked, _ := is.IsWsIPBlocked(key, now.Add(59*time.Minute)); !blocked { + t.Fatal("key should still be blocked before expiry") + } + if blocked, _ := is.IsWsIPBlocked(key, now.Add(time.Hour+time.Second)); blocked { + t.Fatal("key should not be blocked after expiry") + } +} + +func TestWsIPBlocklistExtendAndBreaches(t *testing.T) { + is := &InternalState{} + now := time.Unix(1_700_000_000, 0) + key := "192.0.2.10" + + is.BlockWsIP(key, now.Add(time.Hour), now) + // Re-block before expiry with a later deadline: extends Until, bumps breaches, + // keeps the original BlockedAt. + is.BlockWsIP(key, now.Add(2*time.Hour), now.Add(time.Minute)) + + snap := is.WsBlockedIPsSnapshot(now.Add(time.Minute)) + if len(snap) != 1 { + t.Fatalf("snapshot len = %d, want 1", len(snap)) + } + e := snap[0] + if e.Breaches != 2 { + t.Fatalf("breaches = %d, want 2", e.Breaches) + } + if !e.Until.Equal(now.Add(2 * time.Hour)) { + t.Fatalf("until = %v, want %v", e.Until, now.Add(2*time.Hour)) + } + if !e.BlockedAt.Equal(now) { + t.Fatalf("blockedAt = %v, want %v (original)", e.BlockedAt, now) + } +} + +func TestWsIPBlocklistReblockAfterExpiryResetsWindow(t *testing.T) { + is := &InternalState{} + now := time.Unix(1_700_000_000, 0) + key := "192.0.2.10" + + is.BlockWsIP(key, now.Add(time.Hour), now) + later := now.Add(2 * time.Hour) // after the first block expired + is.BlockWsIP(key, later.Add(time.Hour), later) + + snap := is.WsBlockedIPsSnapshot(later) + if len(snap) != 1 { + t.Fatalf("snapshot len = %d, want 1", len(snap)) + } + if !snap[0].BlockedAt.Equal(later) { + t.Fatalf("blockedAt = %v, want %v (window reset on re-block after expiry)", snap[0].BlockedAt, later) + } + if snap[0].Breaches != 1 { + t.Fatalf("breaches = %d, want 1 after window reset", snap[0].Breaches) + } +} + +func TestWsIPBlocklistRejectedCount(t *testing.T) { + is := &InternalState{} + now := time.Unix(1_700_000_000, 0) + key := "192.0.2.10" + + is.BlockWsIP(key, now.Add(time.Hour), now) + for i := 0; i < 3; i++ { + blocked, rejected := is.IsWsIPBlocked(key, now) + if !blocked || rejected != i+1 { + t.Fatalf("attempt %d: blocked=%v rejected=%d, want true, %d", i, blocked, rejected, i+1) + } + } + // A non-blocking probe of an unblocked key must not record a rejection. + is.IsWsIPBlocked("198.51.100.1", now) + + snap := is.WsBlockedIPsSnapshot(now) + if len(snap) != 1 || snap[0].Rejected != 3 { + t.Fatalf("rejected = %v, want 3", snap) + } +} + +func TestWsIPBlocklistSweep(t *testing.T) { + is := &InternalState{} + now := time.Unix(1_700_000_000, 0) + + is.BlockWsIP("192.0.2.1", now.Add(time.Hour), now) + is.BlockWsIP("192.0.2.2", now.Add(2*time.Hour), now) + + if got := is.SweepWsBlockedIPs(now); got != 2 { + t.Fatalf("sweep before expiry returned %d, want 2", got) + } + // After the first entry expires, sweep drops it and returns the live count. + if got := is.SweepWsBlockedIPs(now.Add(time.Hour + time.Minute)); got != 1 { + t.Fatalf("sweep after one expiry returned %d, want 1", got) + } + snap := is.WsBlockedIPsSnapshot(now.Add(time.Hour + time.Minute)) + if len(snap) != 1 || snap[0].Key != "192.0.2.2" { + t.Fatalf("snapshot after sweep = %v, want only 192.0.2.2", snap) + } +} + +func TestWsIPBlocklistSnapshotOrderAndReset(t *testing.T) { + is := &InternalState{} + now := time.Unix(1_700_000_000, 0) + + is.BlockWsIP("192.0.2.1", now.Add(time.Hour), now) + is.BlockWsIP("192.0.2.2", now.Add(3*time.Hour), now) + is.BlockWsIP("192.0.2.3", now.Add(2*time.Hour), now) + + snap := is.WsBlockedIPsSnapshot(now) + if len(snap) != 3 { + t.Fatalf("snapshot len = %d, want 3", len(snap)) + } + // Sorted by Until descending. + if snap[0].Key != "192.0.2.2" || snap[1].Key != "192.0.2.3" || snap[2].Key != "192.0.2.1" { + t.Fatalf("snapshot order = %v, want [.2 .3 .1] by expiry desc", []string{snap[0].Key, snap[1].Key, snap[2].Key}) + } + + is.ResetWsBlockedIPs() + if got := len(is.WsBlockedIPsSnapshot(now)); got != 0 { + t.Fatalf("after reset snapshot len = %d, want 0", got) + } +} 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 10d1abb76f..37bd524558 100644 --- a/common/metrics.go +++ b/common/metrics.go @@ -1,252 +1,229 @@ package common import ( + "fmt" "reflect" + "strings" "github.com/prometheus/client_golang/prometheus" + "github.com/trezor/blockbook/configs" + yaml "gopkg.in/yaml.v3" ) -// Metrics holds prometheus collectors for various metrics collected by Blockbook +// Metrics holds prometheus collectors for various metrics collected by Blockbook. +// +// The collectors are not declared here; their name/help/type/labels/buckets live in +// configs/metrics.yaml (the single source of truth, embedded via configs.MetricsYAML). +// Each field's `metric:""` tag binds it to a definition in that file, and +// GetMetrics builds and registers the collectors by reflection at startup. Adding a +// metric means adding a struct field with a tag here and a matching entry in the YAML. 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 - 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 `metric:"websocket_requests"` + WebsocketSubscribes *prometheus.GaugeVec `metric:"websocket_subscribes"` + WebsocketClients prometheus.Gauge `metric:"websocket_clients"` + WebsocketReqDuration *prometheus.HistogramVec `metric:"websocket_req_duration"` + WebsocketChannelCloses *prometheus.CounterVec `metric:"websocket_channel_closes"` + WebsocketUnknownMethods *prometheus.CounterVec `metric:"websocket_unknown_methods"` + WebsocketAddrNotifications *prometheus.CounterVec `metric:"websocket_addr_notifications"` + WebsocketNewBlockTxs *prometheus.CounterVec `metric:"websocket_new_block_txs"` + WebsocketNewBlockTxsDuration *prometheus.HistogramVec `metric:"websocket_new_block_txs_duration_seconds"` + BalanceHistoryFiatDuration *prometheus.HistogramVec `metric:"balance_history_fiat_duration_seconds"` + BalanceHistoryFiatFallback *prometheus.CounterVec `metric:"balance_history_fiat_fallback_total"` + BalanceHistoryPoints *prometheus.HistogramVec `metric:"balance_history_points"` + BalanceHistoryTxs *prometheus.HistogramVec `metric:"balance_history_txs"` + BalanceHistoryCapExceeded *prometheus.CounterVec `metric:"balance_history_cap_exceeded_total"` + WebsocketEthReceipt *prometheus.CounterVec `metric:"websocket_eth_receipt"` + WebsocketNewBlockTxsSubscriptions prometheus.Gauge `metric:"websocket_new_block_txs_subscriptions"` + WebsocketNewBlockNotifications prometheus.Counter `metric:"websocket_new_block_notifications"` + WebsocketConnectionRequests prometheus.Histogram `metric:"websocket_connection_requests"` + WebsocketConnectionRejections *prometheus.CounterVec `metric:"websocket_connection_rejections"` + WebsocketUniqueIPs prometheus.Gauge `metric:"websocket_unique_ips"` + WebsocketMaxConnectionsPerIP prometheus.Gauge `metric:"websocket_max_connections_per_ip"` + WebsocketBlockedIPs prometheus.Gauge `metric:"websocket_blocked_ips"` + WebsocketBlockedConnections prometheus.Counter `metric:"websocket_blocked_connections"` + RestUIRateLimitRejections *prometheus.CounterVec `metric:"rest_ui_rate_limit_rejections"` + RestUIActiveIPs prometheus.Gauge `metric:"rest_ui_active_ips"` + RestUIMaxActiveRequestsPerIP prometheus.Gauge `metric:"rest_ui_max_active_requests_per_ip"` + RestUIBlockedIPs prometheus.Gauge `metric:"rest_ui_blocked_ips"` + IndexResyncDuration prometheus.Histogram `metric:"index_resync_duration"` + MempoolResyncDuration prometheus.Histogram `metric:"mempool_resync_duration"` + MempoolResyncThroughput *prometheus.HistogramVec `metric:"mempool_resync_throughput_txs_per_second"` + TxCacheEfficiency *prometheus.CounterVec `metric:"txcache_efficiency"` + RPCLatency *prometheus.HistogramVec `metric:"rpc_latency"` + ChainDataFallbacks *prometheus.CounterVec `metric:"rpc_fallback_calls_total"` + EthCallRequests *prometheus.CounterVec `metric:"eth_call_requests"` + EthCallErrors *prometheus.CounterVec `metric:"eth_call_errors"` + EthCallBatchSize prometheus.Histogram `metric:"eth_call_batch_size"` + EthCallContractInfo *prometheus.CounterVec `metric:"eth_call_contract_info_requests"` + EthCallTokenURI *prometheus.CounterVec `metric:"eth_call_token_uri_requests"` + EthCallStakingPool *prometheus.CounterVec `metric:"eth_call_staking_pool_requests"` + IndexResyncErrors *prometheus.CounterVec `metric:"index_resync_errors"` + IndexBlockNotFoundRetries prometheus.Counter `metric:"index_block_not_found_retries"` + IndexReorgEvents *prometheus.CounterVec `metric:"index_reorg_events"` + IndexSyncYields *prometheus.CounterVec `metric:"index_sync_yields"` + IndexDBSize prometheus.Gauge `metric:"index_db_size"` + ExplorerViews *prometheus.CounterVec `metric:"explorer_views"` + MempoolSize prometheus.Gauge `metric:"mempool_size"` + EthAlternativeMempoolEvents *prometheus.CounterVec `metric:"eth_alternative_mempool_reconciliation_events_total"` + EthAlternativeMempoolTxResidence *prometheus.HistogramVec `metric:"eth_alternative_mempool_tx_residence_seconds"` + EthAlternativeMempoolCacheSize prometheus.Gauge `metric:"eth_alternative_mempool_cache_size"` + EstimatedFee *prometheus.GaugeVec `metric:"estimated_fee"` + AvgBlockPeriod prometheus.Gauge `metric:"avg_block_period"` + SyncBlockStats *prometheus.GaugeVec `metric:"sync_block_stats"` + SyncHotnessStats *prometheus.GaugeVec `metric:"sync_hotness_stats"` + AddrContractsCacheEntries prometheus.Gauge `metric:"addr_contracts_cache_entries"` + AddrContractsCacheBytes prometheus.Gauge `metric:"addr_contracts_cache_bytes"` + AddrContractsCacheHits prometheus.Counter `metric:"addr_contracts_cache_hits_total"` + AddrContractsCacheMisses prometheus.Counter `metric:"addr_contracts_cache_misses_total"` + AddrContractsCacheFlushes *prometheus.CounterVec `metric:"addr_contracts_cache_flush_total"` + DbColumnRows *prometheus.GaugeVec `metric:"dbcolumn_rows"` + DbColumnSize *prometheus.GaugeVec `metric:"dbcolumn_size"` + BlockbookAppInfo *prometheus.GaugeVec `metric:"app_info"` + BlockbookBestHeight prometheus.Gauge `metric:"best_height"` + Synchronized prometheus.Gauge `metric:"synchronized"` + BackendBestHeight prometheus.Gauge `metric:"backend_best_height"` + BackendTipAgeSeconds prometheus.Gauge `metric:"tip_age_seconds"` + BackendSubscriptionAgeSeconds prometheus.Gauge `metric:"backend_subscription_age_seconds"` + BackendSubscriptionEvents *prometheus.CounterVec `metric:"backend_subscription_events"` + AverageBlockTimeSeconds prometheus.Gauge `metric:"average_block_time_seconds"` + ExplorerPendingRequests *prometheus.GaugeVec `metric:"explorer_pending_requests"` + WebsocketPendingRequests *prometheus.GaugeVec `metric:"websocket_pending_requests"` + XPubCacheSize prometheus.Gauge `metric:"xpub_cache_size"` + CoingeckoRequests *prometheus.CounterVec `metric:"coingecko_requests"` + CoingeckoRangeRequests *prometheus.CounterVec `metric:"coingecko_range_requests"` + FiatRatesUpdateDuration *prometheus.HistogramVec `metric:"fiat_rates_update_duration_seconds"` + FiatRatesFetchedUnits *prometheus.CounterVec `metric:"fiat_rates_fetched_units_total"` + FiatRatesFetchedTokens *prometheus.CounterVec `metric:"fiat_rates_fetched_tokens_total"` + FiatRatesUnable *prometheus.CounterVec `metric:"fiat_rates_unable_total"` + AlternativeFeeProviderRequests *prometheus.CounterVec `metric:"alternative_fee_provider_requests"` + EthSyncRpcErrors *prometheus.CounterVec `metric:"eth_sync_rpc_errors"` } // Labels represents a collection of label name -> value mappings. type Labels = prometheus.Labels -// GetMetrics returns struct holding prometheus collectors for various metrics collected by Blockbook +// metricDef is one metric definition as declared in configs/metrics.yaml. +type metricDef struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Help string `yaml:"help"` + Labels []string `yaml:"labels"` + Buckets []float64 `yaml:"buckets"` +} + +// metricsConfig is the parsed configs/metrics.yaml document. +type metricsConfig struct { + Prefix string `yaml:"prefix"` + Metrics map[string]metricDef `yaml:"metrics"` +} + +// GetMetrics builds and registers the prometheus collectors defined in +// configs/metrics.yaml, returning a Metrics struct whose fields point at them. +// Each struct field is matched to a YAML entry by its `metric:""` tag; the +// field's Go type must agree with the entry's declared type. Definitions and struct +// fields must be in 1:1 correspondence, otherwise GetMetrics returns an error. func GetMetrics(coin string) (*Metrics, error) { - metrics := Metrics{} - - metrics.SocketIORequests = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "blockbook_socketio_requests", - Help: "Total number of socketio 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", - ConstLabels: Labels{"coin": coin}, - }, - []string{"channel", "status"}, - ) - metrics.SocketIOClients = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "blockbook_socketio_clients", - Help: "Number of currently connected socketio clients", - ConstLabels: Labels{"coin": coin}, - }, - ) - metrics.SocketIOReqDuration = 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}, - ConstLabels: Labels{"coin": coin}, - }, - []string{"method"}, - ) - metrics.WebsocketRequests = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "blockbook_websocket_requests", - Help: "Total number of websocket requests by method and status", - ConstLabels: Labels{"coin": coin}, - }, - []string{"method", "status"}, - ) - metrics.WebsocketSubscribes = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "blockbook_websocket_subscribes", - Help: "Number of websocket subscriptions by method", - ConstLabels: Labels{"coin": coin}, - }, - []string{"method"}, - ) - metrics.WebsocketClients = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "blockbook_websocket_clients", - Help: "Number of currently connected websocket clients", - ConstLabels: Labels{"coin": coin}, - }, - ) - metrics.WebsocketReqDuration = 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}, - 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}, - ConstLabels: Labels{"coin": coin}, - }, - ) - metrics.MempoolResyncDuration = prometheus.NewHistogram( - 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}, - ConstLabels: Labels{"coin": coin}, - }, - ) - metrics.TxCacheEfficiency = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "blockbook_txcache_efficiency", - Help: "Efficiency of txCache", - ConstLabels: Labels{"coin": coin}, - }, - []string{"status"}, - ) - metrics.RPCLatency = prometheus.NewHistogramVec( - prometheus.HistogramOpts{ - Name: "blockbook_rpc_latency", - Help: "Latency of blockchain RPC by method (in milliseconds)", - Buckets: []float64{0.1, 0.5, 1, 5, 10, 25, 50, 75, 100, 250}, - ConstLabels: Labels{"coin": coin}, - }, - []string{"method", "error"}, - ) - metrics.IndexResyncErrors = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "blockbook_index_resync_errors", - Help: "Number of errors of index resync operation", - ConstLabels: Labels{"coin": coin}, - }, - []string{"error"}, - ) - metrics.IndexDBSize = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "blockbook_index_db_size", - Help: "Size of index database (in bytes)", - ConstLabels: Labels{"coin": coin}, - }, - ) - metrics.ExplorerViews = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "blockbook_explorer_views", - Help: "Number of explorer views", - ConstLabels: Labels{"coin": coin}, - }, - []string{"action"}, - ) - metrics.MempoolSize = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "blockbook_mempool_size", - Help: "Mempool size (number of transactions)", - ConstLabels: Labels{"coin": coin}, - }, - ) - metrics.DbColumnRows = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "blockbook_dbcolumn_rows", - Help: "Number of rows in db column", - ConstLabels: Labels{"coin": coin}, - }, - []string{"column"}, - ) - metrics.DbColumnSize = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "blockbook_dbcolumn_size", - Help: "Size of db column (in bytes)", - ConstLabels: Labels{"coin": coin}, - }, - []string{"column"}, - ) - metrics.BlockbookAppInfo = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "blockbook_app_info", - Help: "Information about blockbook and backend application versions", - ConstLabels: Labels{"coin": coin}, - }, - []string{"blockbook_version", "blockbook_commit", "blockbook_buildtime", "backend_version", "backend_subversion", "backend_protocol_version"}, - ) - metrics.BlockbookBestHeight = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "blockbook_best_height", - Help: "Block height in Blockbook", - ConstLabels: Labels{"coin": coin}, - }, - ) - metrics.BackendBestHeight = prometheus.NewGauge( - prometheus.GaugeOpts{ - Name: "blockbook_backend_best_height", - Help: "Block height in backend", - ConstLabels: Labels{"coin": coin}, - }, - ) - metrics.ExplorerPendingRequests = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "blockbook_explorer_pending_reqests", - Help: "Number of unfinished requests in explorer interface", - ConstLabels: Labels{"coin": coin}, - }, - []string{"method"}, - ) - metrics.WebsocketPendingRequests = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "blockbook_websocket_pending_reqests", - Help: "Number of unfinished requests in websocket interface", - ConstLabels: Labels{"coin": coin}, - }, - []string{"method"}, - ) - metrics.SocketIOPendingRequests = prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "blockbook_socketio_pending_reqests", - 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", - Help: "Number of cached xpubs", - ConstLabels: Labels{"coin": coin}, - }, - ) - - v := reflect.ValueOf(metrics) - for i := 0; i < v.NumField(); i++ { - c := v.Field(i).Interface().(prometheus.Collector) - err := prometheus.Register(c) + var cfg metricsConfig + if err := yaml.Unmarshal(configs.MetricsYAML, &cfg); err != nil { + return nil, fmt.Errorf("metrics: parsing configs/metrics.yaml: %w", err) + } + + metrics := &Metrics{} + constLabels := Labels{"coin": coin} + v := reflect.ValueOf(metrics).Elem() + t := v.Type() + used := make(map[string]bool, len(cfg.Metrics)) + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + key := field.Tag.Get("metric") + if key == "" { + return nil, fmt.Errorf("metrics: field %s has no `metric` tag", field.Name) + } + def, ok := cfg.Metrics[key] + if !ok { + return nil, fmt.Errorf("metrics: no definition for key %q (field %s)", key, field.Name) + } + if used[key] { + return nil, fmt.Errorf("metrics: duplicate `metric:%q` tag (field %s); each definition must bind to exactly one struct field", key, field.Name) + } + used[key] = true + + collector, err := buildCollector(field.Type, def, constLabels) if err != nil { - return nil, err + return nil, fmt.Errorf("metrics: field %s (key %q): %w", field.Name, key, err) + } + if err := prometheus.Register(collector); err != nil { + return nil, fmt.Errorf("metrics: registering %q: %w", def.Name, err) + } + v.Field(i).Set(reflect.ValueOf(collector)) + } + + for key := range cfg.Metrics { + if !used[key] { + return nil, fmt.Errorf("metrics: definition %q in configs/metrics.yaml has no corresponding struct field", key) } } - return &metrics, nil + return metrics, nil +} + +// fieldTypeToMetricType maps a Metrics struct field's Go type to the metric `type` +// its configs/metrics.yaml entry must declare. This is the authoritative binding: it +// is exact (an interface field would otherwise be satisfied by several concrete +// collector types, so type-equality or assignability checks are not tight enough). +var fieldTypeToMetricType = map[string]string{ + "prometheus.Counter": "counter", + "*prometheus.CounterVec": "counter_vec", + "prometheus.Gauge": "gauge", + "*prometheus.GaugeVec": "gauge_vec", + "prometheus.Histogram": "histogram", + "*prometheus.HistogramVec": "histogram_vec", +} + +// buildCollector constructs the prometheus collector described by def, validating that +// its declared type matches the Go type of the destination struct field and is +// internally consistent (labels for *_vec, buckets for histograms). +func buildCollector(fieldType reflect.Type, def metricDef, constLabels Labels) (prometheus.Collector, error) { + want, ok := fieldTypeToMetricType[fieldType.String()] + if !ok { + return nil, fmt.Errorf("unsupported field type %s", fieldType) + } + if def.Type != want { + return nil, fmt.Errorf("type %q does not match struct field type %s (expected %q)", def.Type, fieldType, want) + } + + isVec := strings.HasSuffix(def.Type, "_vec") + isHist := strings.HasPrefix(def.Type, "histogram") + if isVec && len(def.Labels) == 0 { + return nil, fmt.Errorf("type %q requires labels", def.Type) + } + if !isVec && len(def.Labels) > 0 { + return nil, fmt.Errorf("type %q must not declare labels", def.Type) + } + if isHist && len(def.Buckets) == 0 { + return nil, fmt.Errorf("type %q requires buckets", def.Type) + } + if !isHist && len(def.Buckets) > 0 { + return nil, fmt.Errorf("type %q must not declare buckets", def.Type) + } + + var c prometheus.Collector + switch def.Type { + case "counter": + c = prometheus.NewCounter(prometheus.CounterOpts{Name: def.Name, Help: def.Help, ConstLabels: constLabels}) + case "counter_vec": + c = prometheus.NewCounterVec(prometheus.CounterOpts{Name: def.Name, Help: def.Help, ConstLabels: constLabels}, def.Labels) + case "gauge": + c = prometheus.NewGauge(prometheus.GaugeOpts{Name: def.Name, Help: def.Help, ConstLabels: constLabels}) + case "gauge_vec": + c = prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: def.Name, Help: def.Help, ConstLabels: constLabels}, def.Labels) + case "histogram": + c = prometheus.NewHistogram(prometheus.HistogramOpts{Name: def.Name, Help: def.Help, Buckets: def.Buckets, ConstLabels: constLabels}) + case "histogram_vec": + c = prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: def.Name, Help: def.Help, Buckets: def.Buckets, ConstLabels: constLabels}, def.Labels) + default: + return nil, fmt.Errorf("unknown type %q", def.Type) + } + return c, nil } diff --git a/common/metrics_test.go b/common/metrics_test.go new file mode 100644 index 0000000000..ed6c5e652d --- /dev/null +++ b/common/metrics_test.go @@ -0,0 +1,88 @@ +//go:build unittest + +package common + +import ( + "reflect" + "strings" + "sync" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/trezor/blockbook/configs" + yaml "gopkg.in/yaml.v3" +) + +var prometheusRegistryMu sync.Mutex + +func useTestPrometheusRegistry(t *testing.T) { + t.Helper() + + prometheusRegistryMu.Lock() + oldRegisterer := prometheus.DefaultRegisterer + oldGatherer := prometheus.DefaultGatherer + registry := prometheus.NewRegistry() + prometheus.DefaultRegisterer = registry + prometheus.DefaultGatherer = registry + + t.Cleanup(func() { + prometheus.DefaultRegisterer = oldRegisterer + prometheus.DefaultGatherer = oldGatherer + prometheusRegistryMu.Unlock() + }) +} + +// TestGetMetrics verifies that every field of the Metrics struct is bound to a +// definition in configs/metrics.yaml, of a matching type, and that the resulting +// collectors are all constructed (non-nil) after loading. +func TestGetMetrics(t *testing.T) { + useTestPrometheusRegistry(t) + + m, err := GetMetrics("metrics_unittest") + if err != nil { + t.Fatalf("GetMetrics: %v", err) + } + v := reflect.ValueOf(m).Elem() + tp := v.Type() + for i := 0; i < tp.NumField(); i++ { + if v.Field(i).IsNil() { + t.Errorf("field %s was not initialized from configs/metrics.yaml", tp.Field(i).Name) + } + if tag := tp.Field(i).Tag.Get("metric"); tag == "" { + t.Errorf("field %s is missing its `metric` tag", tp.Field(i).Name) + } + } +} + +// TestMetricsYAMLInvariants checks the embedded single-source-of-truth file for the +// invariants the loader and the Grafana renderer both rely on: 1:1 correspondence with +// the struct, unique prometheus names, the common prefix, and key/name being distinct +// enough that the stable-key indirection holds (key never carries the prefix). +func TestMetricsYAMLInvariants(t *testing.T) { + var cfg metricsConfig + if err := yaml.Unmarshal(configs.MetricsYAML, &cfg); err != nil { + t.Fatalf("parsing embedded metrics.yaml: %v", err) + } + if cfg.Prefix == "" { + t.Fatal("metrics.yaml: prefix must be set") + } + + numFields := reflect.TypeOf(Metrics{}).NumField() + if len(cfg.Metrics) != numFields { + t.Errorf("metrics.yaml has %d entries but Metrics struct has %d fields (must be 1:1)", len(cfg.Metrics), numFields) + } + + names := make(map[string]string, len(cfg.Metrics)) + for key, def := range cfg.Metrics { + if !strings.HasPrefix(def.Name, cfg.Prefix) { + t.Errorf("metric %q: name %q does not start with prefix %q", key, def.Name, cfg.Prefix) + } + if strings.HasPrefix(key, cfg.Prefix) { + t.Errorf("metric %q: stable key must not carry the %q prefix", key, cfg.Prefix) + } + if prev, dup := names[def.Name]; dup { + t.Errorf("duplicate prometheus name %q (keys %q and %q)", def.Name, prev, key) + } + names[def.Name] = key + } +} diff --git a/common/utils.go b/common/utils.go new file mode 100644 index 0000000000..4dee4686e0 --- /dev/null +++ b/common/utils.go @@ -0,0 +1,108 @@ +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 +func TickAndDebounce(tickTime time.Duration, debounceTime time.Duration, trigger chan struct{}, f func()) { + timer := time.NewTimer(tickTime) + var firstDebounce time.Time +Loop: + for { + select { + case _, ok := <-trigger: + if !timer.Stop() { + <-timer.C + } + // exit loop on closed input channel + if !ok { + break Loop + } + if firstDebounce.IsZero() { + firstDebounce = time.Now() + } + // debounce for up to debounceTime period + // afterwards execute immediately + if firstDebounce.Add(debounceTime).After(time.Now()) { + timer.Reset(debounceTime) + } else { + timer.Reset(0) + } + case <-timer.C: + // do the action, if not in shutdown, then start the loop again + if !IsInShutdown() { + f() + } + timer.Reset(tickTime) + firstDebounce = time.Time{} + } + } +} + +// 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..ec8b5eb6ff --- /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": 1800, + "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..4611e59218 --- /dev/null +++ b/configs/coins/arbitrum_archive.json @@ -0,0 +1,81 @@ +{ + "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": 1800, + "additional_params": { + "averageBlockTimeMs": 250, + "missingBlockRetry": { + "retryDelayMs": 1000, + "recheckThreshold": 10, + "tipRecheckThreshold": 3, + "maxStallMs": 60000 + }, + "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": "10s", + "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..513d159262 --- /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": 1800, + "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..41272e4bef --- /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": 1800, + "additional_params": { + "averageBlockTimeMs": 250, + "address_aliases": true, + "mempoolTxTimeoutHours": 12, + "processInternalTransactions": true, + "trace_timeout": "10s", + "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 new file mode 100644 index 0000000000..2cde02340a --- /dev/null +++ b/configs/coins/avalanche.json @@ -0,0 +1,74 @@ +{ + "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, + "eip1559Fees": true, + "alternative_estimate_fee": "infura", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/43114/suggestedGasFees\", \"periodSeconds\": 60}", + "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" + } +} diff --git a/configs/coins/avalanche_archive.json b/configs/coins/avalanche_archive.json new file mode 100644 index 0000000000..e1c2c3cf35 --- /dev/null +++ b/configs/coins/avalanche_archive.json @@ -0,0 +1,85 @@ +{ + "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, + "missingBlockRetry": { + "retryDelayMs": 1000, + "recheckThreshold": 10, + "tipRecheckThreshold": 3, + "maxStallMs": 60000 + }, + "address_aliases": true, + "eip1559Fees": true, + "alternative_estimate_fee": "infura", + "alternative_estimate_fee_params": "{\"url\": \"https://gas.api.infura.io/v3/${api_key}/networks/43114/suggestedGasFees\", \"periodSeconds\": 60}", + "mempoolTxTimeoutHours": 12, + "processInternalTransactions": true, + "trace_timeout": "10s", + "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" + } +} 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..235a9f3279 --- /dev/null +++ b/configs/coins/base_archive.json @@ -0,0 +1,84 @@ +{ + "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, + "missingBlockRetry": { + "retryDelayMs": 1000, + "recheckThreshold": 10, + "tipRecheckThreshold": 3, + "maxStallMs": 60000 + }, + "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": "10s", + "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\": \"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 c0b73fad8b..b7ed6b0c17 100644 --- a/configs/coins/bcash.json +++ b/configs/coins/bcash.json @@ -1,69 +1,70 @@ { - "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": "22.1.0", - "binary_url": "https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v22.1.0/bitcoin-cash-node-22.1.0-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "aa1002d51833b0de44084bde09951223be4f9c455427aef277f91dacd2f0f657", - "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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcoin-cash\", \"periodSeconds\": 60}" - } + "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": { + "averageBlockTimeMs": 600000, + "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 e1866befa4..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": "22.1.0", - "binary_url": "https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v22.1.0/bitcoin-cash-node-22.1.0-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "aa1002d51833b0de44084bde09951223be4f9c455427aef277f91dacd2f0f657", - "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 13a445c19f..f1daddf5a6 100644 --- a/configs/coins/bgold.json +++ b/configs/coins/bgold.json @@ -1,265 +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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcoin-gold\", \"periodSeconds\": 60}" - } - } - }, - "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 new file mode 100644 index 0000000000..0f67c9b1dc --- /dev/null +++ b/configs/coins/bgold_testnet.json @@ -0,0 +1,79 @@ +{ + "coin": { + "name": "Bgold Testnet", + "shortcut": "TBTG", + "label": "Bitcoin Gold Testnet", + "alias": "bgold_testnet" + }, + "ports": { + "backend_rpc": 18035, + "backend_message_queue": 48335, + "blockbook_internal": 19035, + "blockbook_public": 19135 + }, + "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-testnet", + "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/testnet/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "server_config_file": "bitcoin_like.conf", + "client_config_file": "bitcoin_like_client.conf", + "additional_params": { + "addnode": [ + "136.243.230.235:18338", + "167.179.114.118:18338", + "51.15.140.154:18338", + "62.141.35.88:18338", + "71.172.96.60:18338", + "8.39.234.187:18338" + ], + "maxconnections": 250, + "mempoolexpiry": 72, + "timeout": 768 + } + }, + "blockbook": { + "package_name": "blockbook-bgold-testnet", + "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": 70617039, + "xpub_magic_segwit_p2sh": 71979618, + "xpub_magic_segwit_native": 73342198, + "slip44": 156, + "additional_params": {} + } + }, + "meta": { + "package_maintainer": "Martin Kuvandzhiev", + "package_maintainer_email": "martin@bitcoingold.org" + } +} diff --git a/configs/coins/bitcoin.json b/configs/coins/bitcoin.json index 58aa1df149..83ee279930 100644 --- a/configs/coins/bitcoin.json +++ b/configs/coins/bitcoin.json @@ -1,73 +1,88 @@ { - "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": "0.20.1", - "binary_url": "https://bitcoin.org/bin/bitcoin-core-0.20.1/bitcoin-0.20.1-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://bitcoin.org/bin/bitcoin-core-0.20.1/SHA256SUMS.asc", - "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" + }, + "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": { + "averageBlockTimeMs": 600000, + "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" } - }, - "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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcoin\", \"periodSeconds\": 60}" - } - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/bitcoin_regtest.json b/configs/coins/bitcoin_regtest.json new file mode 100644 index 0000000000..a35179db29 --- /dev/null +++ b/configs/coins/bitcoin_regtest.json @@ -0,0 +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_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" + } +} diff --git a/configs/coins/bitcoin_signet.json b/configs/coins/bitcoin_signet.json index 5eab9c235d..42fb3cd9b5 100644 --- a/configs/coins/bitcoin_signet.json +++ b/configs/coins/bitcoin_signet.json @@ -1,69 +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": "23.0", - "binary_url": "https://bitcoincore.org/bin/bitcoin-core-23.0/bitcoin-23.0-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "2cca490c1f2842884a3c5b0606f179f9f937177da4eadd628e3f7fd7e25d26d0", - "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" + }, + "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" } - }, - "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" - } } diff --git a/configs/coins/bitcoin_testnet.json b/configs/coins/bitcoin_testnet.json index b155886790..9c6b68ce7e 100644 --- a/configs/coins/bitcoin_testnet.json +++ b/configs/coins/bitcoin_testnet.json @@ -1,69 +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": "0.20.1", - "binary_url": "https://bitcoin.org/bin/bitcoin-core-0.20.1/bitcoin-0.20.1-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://bitcoin.org/bin/bitcoin-core-0.20.1/SHA256SUMS.asc", - "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" + }, + "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" } - }, - "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": {} - } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } -} \ No newline at end of file +} 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 8cf72d54a1..5a04cbbf0e 100644 --- a/configs/coins/bitcore.json +++ b/configs/coins/bitcore.json @@ -1,71 +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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcore\", \"periodSeconds\": 60}" - } - } - }, - "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..bd8949ae79 --- /dev/null +++ b/configs/coins/bsc_archive.json @@ -0,0 +1,89 @@ +{ + "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, + "missingBlockRetry": { + "retryDelayMs": 1000, + "recheckThreshold": 10, + "tipRecheckThreshold": 3, + "maxStallMs": 60000 + }, + "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": "10s", + "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 94e1060a19..46bcdbc863 100644 --- a/configs/coins/dash.json +++ b/configs/coins/dash.json @@ -1,71 +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": "0.16.0.1", - "binary_url": "https://github.com/dashpay/dash/releases/download/v0.16.0.1/dashcore-0.16.0.1-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://github.com/dashpay/dash/releases/download/v0.16.0.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.14.0.5/", - "mempool_workers": 8, - "mempool_sub_workers": 2, - "block_addresses_to_keep": 300, - "xpub_magic": 50221772, - "slip44": 5, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"dash\", \"periodSeconds\": 60}" - } - } - }, - "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 c4378e0fae..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": "0.16.0.1", - "binary_url": "https://github.com/dashpay/dash/releases/download/v0.16.0.1/dashcore-0.16.0.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/v0.16.0.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" @@ -52,7 +53,7 @@ "additional_params": "", "block_chain": { "parse": true, - "subversion": "/Dash Core:0.14.0.5/", + "subversion": "/Dash Core:0.17.0.3/", "mempool_workers": 8, "mempool_sub_workers": 2, "block_addresses_to_keep": 300, @@ -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 64f57ddaf0..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, @@ -22,10 +23,10 @@ "package_name": "backend-decred", "package_revision": "decred-1", "system_user": "decred", - "version": "1.4.0", - "binary_url": "https://github.com/decred/decred-binaries/releases/download/v1.4.0/decred-linux-amd64-v1.4.0.tar.gz", + "version": "1.7.5", + "binary_url": "https://github.com/decred/decred-binaries/releases/download/v1.7.5/decred-linux-amd64-v1.7.5.tar.gz", "verification_type": "sha256", - "verification_source": "36375985df1ba9a45bc11b4f6cdaed4f14ff6e5e9c46e17ef6e4f70a3349aba2", + "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 --appdata={{.Env.BackendDataPath}}/{{.Coin.Alias}} -C={{.Env.BackendInstallPath}}/{{.Coin.Alias}}/{{.Coin.Alias}}.conf", @@ -52,7 +53,7 @@ "additional_params": "-resyncindexperiod=300111 -resyncmempoolperiod=60111", "block_chain": { "parse": true, - "subversion":"/Decred dcrd:1.4.0", + "subversion":"/Decred dcrd:1.7.5", "mempool_workers": 8, "mempool_sub_workers": 2, "block_addresses_to_keep": 30, diff --git a/configs/coins/decred_testnet.json b/configs/coins/decred_testnet.json index 21dccd3caa..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, @@ -22,21 +23,21 @@ "package_name": "backend-decred-testnet", "package_revision": "decred-testnet-1", "system_user": "decred", - "version": "1.4.0", - "binary_url": "https://github.com/decred/decred-binaries/releases/download/v1.4.0/decred-linux-amd64-v1.4.0.tar.gz", + "version": "1.7.5", + "binary_url": "https://github.com/decred/decred-binaries/releases/download/v1.7.5/decred-linux-amd64-v1.7.5.tar.gz", "verification_type": "sha256", - "verification_source": "36375985df1ba9a45bc11b4f6cdaed4f14ff6e5e9c46e17ef6e4f70a3349aba2", + "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}}/dcrd.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", "service_additional_params_template": "", "protect_memory": false, "mainnet": false, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", + "server_config_file": "decred.conf", + "client_config_file": "decred_client.conf", "additional_params": { "rpcmaxclients": 16, "upnp": 0, @@ -52,7 +53,7 @@ "additional_params": "-resyncindexperiod=300111 -resyncmempoolperiod=60111", "block_chain": { "parse": true, - "subversion":"/Decred dcrd:1.4.0", + "subversion":"/Decred dcrd:1.7.5", "mempool_workers": 8, "mempool_sub_workers": 2, "block_addresses_to_keep": 30, diff --git a/configs/coins/deeponion.json b/configs/coins/deeponion.json index fee9476791..b71191eebd 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, @@ -24,6 +25,8 @@ "system_user": "deeponion", "version": "2.2.0", "binary_url": "https://github.com/deeponion/deeponion/releases/download/v2.2.0/DeepOnion-2.2.0-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "c4876ddf71cf489fdf1be6e048a732800d313b48ac1c5c058810ff29ee4b1b5b", "extract_command": "tar -C backend --strip 1 -xpf", "exclude_files": [ "bin/DeepOnion-qt" diff --git a/configs/coins/digibyte.json b/configs/coins/digibyte.json index 97f3d1194c..8121724bfe 100644 --- a/configs/coins/digibyte.json +++ b/configs/coins/digibyte.json @@ -1,72 +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.2", - "binary_url": "https://github.com/digibyte/digibyte/releases/download/v7.17.2/digibyte-7.17.2-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "caa8ecc42cbebbd2c43e742c7ecc2dd21d76a9e2db23676af428b67b131f6413", - "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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"digibyte\", \"periodSeconds\": 60}" - } - } - }, - "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 a9fc86a277..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, @@ -22,10 +23,10 @@ "package_name": "backend-digibyte-testnet", "package_revision": "satoshilabs-1", "system_user": "digibyte", - "version": "7.17.2", - "binary_url": "https://github.com/digibyte/digibyte/releases/download/v7.17.2/digibyte-7.17.2-x86_64-linux-gnu.tar.gz", + "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": "caa8ecc42cbebbd2c43e742c7ecc2dd21d76a9e2db23676af428b67b131f6413", + "verification_source": "b5cd8f590d359e4846dd5cbe60751221e54d773a6227ea9686d17c4890057f46", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/digibyte-qt" diff --git a/configs/coins/divi.json b/configs/coins/divi.json index d07c9d974d..1ba922fa52 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, @@ -24,6 +25,8 @@ "system_user": "divi", "version": "1.0.4", "binary_url": "https://github.com/DiviProject/Divi/releases/download/v1.0.4-core/divi_ubuntu.zip", + "verification_type": "sha256", + "verification_source": "daf30b2d51d0869c07917157bcd3ef770fe1a006ab1d1ee0887c5ba1063ae0b7", "extract_command": "unzip -j -d backend", "exclude_files": [ "divi-cli", @@ -37,10 +40,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 cd42ca6580..eb71d2fcf5 100644 --- a/configs/coins/dogecoin.json +++ b/configs/coins/dogecoin.json @@ -1,73 +1,81 @@ { - "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.2", - "binary_url": "https://github.com/dogecoin/dogecoin/releases/download/v1.14.2/dogecoin-1.14.2-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "10c400c8f2039b1f804b8a533266201a9e4e3b32a8854501e8a43792e1ee78e6", - "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" + }, + "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": { + "averageBlockTimeMs": 60000, + "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" } - }, - "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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"dogecoin\", \"periodSeconds\": 60}" - } - } - }, - "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 7dece87b32..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" @@ -44,6 +45,13 @@ "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": { diff --git a/configs/coins/ecash.json b/configs/coins/ecash.json index ec51d13d47..821cff06d5 100644 --- a/configs/coins/ecash.json +++ b/configs/coins/ecash.json @@ -1,73 +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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ecash\", \"periodSeconds\": 60}" - } - } - }, - "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 0674fc957d..e20feccdbe 100644 --- a/configs/coins/ethereum-classic.json +++ b/configs/coins/ethereum-classic.json @@ -1,62 +1,69 @@ { - "coin": { - "name": "Ethereum Classic", - "shortcut": "ETC", - "label": "Ethereum Classic", - "alias": "ethereum-classic" - }, - "ports": { - "backend_rpc": 8037, - "backend_message_queue": 0, - "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.11.18", - "binary_url": "https://github.com/etclabscore/core-geth/releases/download/v1.11.18/core-geth-linux-v1.11.18.zip", - "verification_type": "sha256", - "verification_source": "ee1533e546e9520eeb327be30978b55449cce09341c3d242f4104ae41b2c9c2c", - "extract_command": "unzip -d backend", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth --classic --ipcdisable --cache 1024 --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --port 38337 --ws --wsaddr 127.0.0.1 --wsport {{.Ports.BackendRPC}} --wsorigins \"*\" --rpc --rpcport 8137 -rpcaddr 127.0.0.1 --rpccorsdomain \"*\" 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": { - "mempoolTxTimeoutHours": 48, - "queryBackendOnMempoolResync": true, - "fiat_rates": "coingecko", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ethereum-classic\", \"periodSeconds\": 60}" - } + "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 aa138a6235..e20a5d2a57 100644 --- a/configs/coins/ethereum.json +++ b/configs/coins/ethereum.json @@ -1,64 +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, - "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.9.24-cc05b050", - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.9.24-cc05b050.tar.gz", - "verification_type": "gpg", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.9.24-cc05b050.tar.gz.asc", - "extract_command": "tar -C backend --strip 1 -xf", - "exclude_files": [], - "exec_command_template": "/bin/sh -c '{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/geth --ipcdisable --syncmode full --cache 1024 --nat none --datadir {{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend --port 38336 --ws --wsaddr 127.0.0.1 --wsport {{.Ports.BackendRPC}} --wsorigins \"*\" --rpc --rpcport 8136 -rpcaddr 127.0.0.1 --rpccorsdomain \"*\" --rpcvhosts \"*\" 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", - "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": { - "mempoolTxTimeoutHours": 48, - "queryBackendOnMempoolResync": false, - "fiat_rates": "coingecko", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ethereum\", \"periodSeconds\": 60}" - } + "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" } - }, - "meta": { - "package_maintainer": "IT", - "package_maintainer_email": "it@satoshilabs.com" - } } diff --git a/configs/coins/ethereum_archive.json b/configs/coins/ethereum_archive.json new file mode 100644 index 0000000000..bb609155a6 --- /dev/null +++ b/configs/coins/ethereum_archive.json @@ -0,0 +1,88 @@ +{ + "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, + "missingBlockRetry": { + "retryDelayMs": 1000, + "recheckThreshold": 10, + "tipRecheckThreshold": 3, + "maxStallMs": 60000 + }, + "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" + } +} diff --git a/configs/coins/ethereum_archive_consensus.json b/configs/coins/ethereum_archive_consensus.json new file mode 100644 index 0000000000..d3d10379a5 --- /dev/null +++ b/configs/coins/ethereum_archive_consensus.json @@ -0,0 +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": "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" + } +} diff --git a/configs/coins/ethereum_consensus.json b/configs/coins/ethereum_consensus.json new file mode 100644 index 0000000000..8e898d9619 --- /dev/null +++ b/configs/coins/ethereum_consensus.json @@ -0,0 +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": "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" + } +} diff --git a/configs/coins/ethereum_testnet_goerli.json b/configs/coins/ethereum_testnet_goerli.json deleted file mode 100644 index 0e70afbeb9..0000000000 --- a/configs/coins/ethereum_testnet_goerli.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "coin": { - "name": "Ethereum Testnet Goerli", - "shortcut": "gGOE", - "label": "Ethereum Goerli", - "alias": "ethereum_testnet_goerli" - }, - "ports": { - "backend_rpc": 18026, - "backend_message_queue": 0, - "backend_p2p": 48326, - "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.10.21-67109427", - "binary_url": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.21-67109427.tar.gz", - "verification_type": "gpg", - "verification_source": "https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.21-67109427.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 48326 --ws --ws.addr 127.0.0.1 --ws.port {{.Ports.BackendRPC}} --ws.origins \"*\" 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": "" - }, - "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": 300, - "additional_params": { - "mempoolTxTimeoutHours": 12, - "queryBackendOnMempoolResync": false - } - } - }, - "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_sepolia.json b/configs/coins/ethereum_testnet_sepolia.json new file mode 100644 index 0000000000..b2a4d4fadd --- /dev/null +++ b/configs/coins/ethereum_testnet_sepolia.json @@ -0,0 +1,73 @@ +{ + "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" + } +} diff --git a/configs/coins/ethereum_testnet_sepolia_archive.json b/configs/coins/ethereum_testnet_sepolia_archive.json new file mode 100644 index 0000000000..05344018c3 --- /dev/null +++ b/configs/coins/ethereum_testnet_sepolia_archive.json @@ -0,0 +1,80 @@ +{ + "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" + } +} diff --git a/configs/coins/ethereum_testnet_sepolia_archive_consensus.json b/configs/coins/ethereum_testnet_sepolia_archive_consensus.json new file mode 100644 index 0000000000..5fdfdb29b8 --- /dev/null +++ b/configs/coins/ethereum_testnet_sepolia_archive_consensus.json @@ -0,0 +1,53 @@ +{ + "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" + } +} diff --git a/configs/coins/ethereum_testnet_sepolia_consensus.json b/configs/coins/ethereum_testnet_sepolia_consensus.json new file mode 100644 index 0000000000..07ae25515f --- /dev/null +++ b/configs/coins/ethereum_testnet_sepolia_consensus.json @@ -0,0 +1,53 @@ +{ + "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" + } +} diff --git a/configs/coins/firo.json b/configs/coins/firo.json new file mode 100644 index 0000000000..f514651fbd --- /dev/null +++ b/configs/coins/firo.json @@ -0,0 +1,69 @@ +{ + "coin": { + "name": "Firo", + "shortcut": "FIRO", + "label": "Firo", + "alias": "firo" + }, + "ports": { + "backend_rpc": 8050, + "backend_message_queue": 38350, + "blockbook_internal": 9050, + "blockbook_public": 9150 + }, + "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-firo", + "package_revision": "satoshilabs-1", + "system_user": "firo", + "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": "df6d0fb6abc8998909ecb3c3f4c5aa0b6bbf00474bb4739349d12079874754fc", + "extract_command": "tar -C backend --strip 1 -xf", + "exclude_files": [ + "bin/firo-qt", + "bin/firo-tx", + "README.md" + ], + "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/firod -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-firo", + "system_user": "blockbook-firo", + "internal_binding_template": ":{{.Ports.BlockbookInternal}}", + "public_binding_template": ":{{.Ports.BlockbookPublic}}", + "explorer_url": "", + "additional_params": "", + "block_chain": { + "mempool_workers": 8, + "mempool_sub_workers": 2, + "block_addresses_to_keep": 300, + "xpub_magic": 76067358, + "slip44": 136, + "additional_params": {} + } + }, + "meta": { + "package_maintainer": "Putta Khunchalee", + "package_maintainer_email": "putta@zcoin.io" + } +} 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 f20cf36ea2..c2cf8ca7dc 100644 --- a/configs/coins/fujicoin.json +++ b/configs/coins/fujicoin.json @@ -1,65 +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": "0.16.3", - "binary_url": "https://www.fujicoin.org/fujicoin/3.0/fujicoin-blockbook.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://www.fujicoin.org/fujicoin/3.0/SHA256SUMS.asc", - "extract_command": "tar -C backend -xf", - "exclude_files": [], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/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": {} - }, - "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": {} + "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" } - }, - "meta": { - "package_maintainer": "Motty", - "package_maintainer_email": "fujicoin@gmail.com" - } -} \ No newline at end of file +} 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 acc389f4c8..ccb44707e7 100644 --- a/configs/coins/groestlcoin.json +++ b/configs/coins/groestlcoin.json @@ -1,73 +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": "2.20.1", - "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v2.20.1/groestlcoin-2.20.1-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://github.com/Groestlcoin/groestlcoin/releases/download/v2.20.1/SHA256SUMS.asc", - "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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"groestlcoin\", \"periodSeconds\": 60}" - } - } - }, - "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 bbfcfceb2e..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": "23.0", - "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v23.0/groestlcoin-23.0-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "46ab078422d0d2aaf5b89ac9603cb61a6ebf6c26a73b9440365a4df5f9bce7de", - "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" + }, + "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" } - }, - "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": { - "fiat_rates": "coingecko", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"groestlcoin\", \"periodSeconds\": 60}" - } - } - }, - "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 08d401b4e8..cc7ebda29d 100644 --- a/configs/coins/groestlcoin_signet.json +++ b/configs/coins/groestlcoin_signet.json @@ -1,73 +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": "23.0", - "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v23.0/groestlcoin-23.0-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "46ab078422d0d2aaf5b89ac9603cb61a6ebf6c26a73b9440365a4df5f9bce7de", - "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, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"groestlcoin\", \"periodSeconds\": 60}" - } - } - }, - "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 3f2aca3f2c..be02227f56 100644 --- a/configs/coins/groestlcoin_testnet.json +++ b/configs/coins/groestlcoin_testnet.json @@ -1,73 +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": "2.20.1", - "binary_url": "https://github.com/Groestlcoin/groestlcoin/releases/download/v2.20.1/groestlcoin-2.20.1-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://github.com/Groestlcoin/groestlcoin/releases/download/v2.20.1/SHA256SUMS.asc", - "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, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"groestlcoin\", \"periodSeconds\": 60}" - } - } - }, - "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 4630bf6a78..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, @@ -22,10 +23,10 @@ "package_name": "backend-koto", "package_revision": "satoshilabs-1", "system_user": "koto", - "version": "4.0.0", - "binary_url": "https://github.com/KotoDevelopers/koto/releases/download/v4.0.0/koto-4.0.0-linux64.tar.gz", + "version": "4.5.1", + "binary_url": "https://github.com/KotoDevelopers/koto/releases/download/v4.5.1/koto-4.5.1-linux64.tar.gz", "verification_type": "gpg", - "verification_source": "https://github.com/KotoDevelopers/koto/releases/download/v4.0.0/koto-4.0.0-linux64.tar.gz.asc", + "verification_source": "https://github.com/KotoDevelopers/koto/releases/download/v4.5.1/koto-4.5.1-linux64.tar.gz.asc", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/koto-qt" diff --git a/configs/coins/koto_testnet.json b/configs/coins/koto_testnet.json index 120b3a691e..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, @@ -22,10 +23,10 @@ "package_name": "backend-koto-testnet", "package_revision": "satoshilabs-1", "system_user": "koto", - "version": "4.0.0", - "binary_url": "https://github.com/KotoDevelopers/koto/releases/download/v4.0.0/koto-4.0.0-linux64.tar.gz", + "version": "4.5.1", + "binary_url": "https://github.com/KotoDevelopers/koto/releases/download/v4.5.1/koto-4.5.1-linux64.tar.gz", "verification_type": "gpg", - "verification_source": "https://github.com/KotoDevelopers/koto/releases/download/v4.0.0/koto-4.0.0-linux64.tar.gz.asc", + "verification_source": "https://github.com/KotoDevelopers/koto/releases/download/v4.5.1/koto-4.5.1-linux64.tar.gz.asc", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/koto-qt" 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 96cdf04a7d..51253bc8e5 100644 --- a/configs/coins/litecoin.json +++ b/configs/coins/litecoin.json @@ -1,72 +1,79 @@ { - "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.18.1", - "binary_url": "https://download.litecoin.org/litecoin-0.18.1/linux/litecoin-0.18.1-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://download.litecoin.org/litecoin-0.18.1/linux/litecoin-0.18.1-linux-signatures.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" + }, + "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": { + "averageBlockTimeMs": 150000, + "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" } - }, - "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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"litecoin\", \"periodSeconds\": 60}" - } - } - }, - "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 9dd23e39b3..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.18.1", - "binary_url": "https://download.litecoin.org/litecoin-0.18.1/linux/litecoin-0.18.1-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://download.litecoin.org/litecoin-0.18.1/linux/litecoin-0.18.1-linux-signatures.asc", + "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" @@ -41,6 +42,12 @@ "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": { @@ -66,4 +73,4 @@ "package_maintainer": "IT", "package_maintainer_email": "it@satoshilabs.com" } -} \ No newline at end of file +} diff --git a/configs/coins/monacoin.json b/configs/coins/monacoin.json index f8f2c1d3bb..615ab0c55a 100644 --- a/configs/coins/monacoin.json +++ b/configs/coins/monacoin.json @@ -1,72 +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.17.1", - "binary_url": "https://github.com/monacoinproject/monacoin/releases/download/monacoin-0.17.1/monacoin-0.17.1-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://github.com/monacoinproject/monacoin/releases/download/monacoin-0.17.1/monacoin-0.17.1-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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"monacoin\", \"periodSeconds\": 60}" - } - } - }, - "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 6a62d4feb1..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.17.1", - "binary_url": "https://github.com/monacoinproject/monacoin/releases/download/monacoin-0.17.1/monacoin-0.17.1-x86_64-linux-gnu.tar.gz", - "verification_type": "gpg-sha256", - "verification_source": "https://github.com/monacoinproject/monacoin/releases/download/monacoin-0.17.1/monacoin-0.17.1-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 f821439bff..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, @@ -22,10 +23,10 @@ "package_name": "backend-myriad", "package_revision": "satoshilabs-1", "system_user": "myriad", - "version": "0.16.4.0", - "binary_url": "https://github.com/myriadteam/myriadcoin/releases/download/v0.16.4.0/myriadcoin-0.16.4.0-x86_64-linux-gnu.tar.gz", + "version": "0.18.1.0", + "binary_url": "https://github.com/myriadteam/myriadcoin/releases/download/v0.18.1.0/myriadcoin-0.18.1.0-x86_64-linux-gnu.tar.gz", "verification_type": "sha256", - "verification_source": "a2761eaa948e30b1de419f9fc2a07eb3efe852cb2bf76aa371a5e40c959929d8", + "verification_source": "b1af5682ecd89b6d8be284218bb503441b24b231a305d5a8cebb3c63889990b6", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/myriadcoin-qt" @@ -66,4 +67,4 @@ "package_maintainer": "wlc-", "package_maintainer_email": "wwwwllllcccc@gmail.com" } -} \ No newline at end of file +} diff --git a/configs/coins/namecoin.json b/configs/coins/namecoin.json index 441777b3bb..ce1422519f 100644 --- a/configs/coins/namecoin.json +++ b/configs/coins/namecoin.json @@ -1,77 +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.19.0.1", - "binary_url": "https://www.namecoin.org/files/namecoin-core/namecoin-core-0.19.0.1/namecoin-0.19.0.1-x86_64-linux-gnu.tar.gz", - "verification_type": "sha256", - "verification_source": "16acef267c5458ea3f5a7fdfd3482bb32102143ce24a94951a044dc53bc7021b", - "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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"namecoin\", \"periodSeconds\": 60}" - } - } - }, - "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 4d3c9b3cd8..b1ae419691 100644 --- a/configs/coins/omotenashicoin.json +++ b/configs/coins/omotenashicoin.json @@ -1,70 +1,70 @@ { "coin": { "name": "Omotenashicoin", - "shortcut": "MTNS", - "label": "Omotenashicoin", - "alias": "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", + "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://raw.githubusercontent.com/omotenashicoin-project/OmotenashiCoin-HDwalletbinaries/03bde4168217f8d8afe421f5c3fe9970a84d7ab5/stable/omotenashicoin-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "1275cd19715ad4e255b92d782cf969314174505eaeb5b26ff6e3b9d40d6b3815", + "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": { - "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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"omotenashicoin\", \"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": "{\"coin\": \"omotenashicoin\", \"periodSeconds\": 900}" } - }, - "meta": { - "package_maintainer": "omotenashicoin dev", - "package_maintainer_email": "git@omotenashicoin.site" } + }, + "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 bd14828fa8..4127722892 100644 --- a/configs/coins/omotenashicoin_testnet.json +++ b/configs/coins/omotenashicoin_testnet.json @@ -1,70 +1,65 @@ { - "coin": { - "name": "Omotenashicoin Testnet", - "shortcut": "tMTNS", - "label": "Omotenashicoin Testnet", - "alias": "omotenashicoin_testnet" - }, - "ports": { - "blockbook_internal": 19089, - "blockbook_public": 19189, - "backend_rpc": 18089, - "backend_message_queue": 48389 - }, - "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-testnet", - "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/testnet4/*.log", - "postinst_script_template": "", - "service_type": "forking", - "service_additional_params_template": "", - "protect_memory": true, - "mainnet": false, - "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-testnet", - "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": 70544129, - "slip44": 1, - "additional_params": { - "fiat_rates": "coingecko", - "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"omotenashicoin\", \"periodSeconds\": 60}" - } - } - }, - "meta": { - "package_maintainer": "omotenashicoin dev", - "package_maintainer_email": "git@omotenashicoin.site" - } + "coin": { + "name": "Omotenashicoin Testnet", + "shortcut": "tMTNS", + "label": "Omotenashicoin Testnet", + "alias": "omotenashicoin_testnet" + }, + "ports": { + "blockbook_internal": 19089, + "blockbook_public": 19189, + "backend_rpc": 18089, + "backend_message_queue": 48389 + }, + "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-testnet", + "package_revision": "satoshilabs-1", + "system_user": "mtns", + "version": "1.7.3", + "binary_url": "https://raw.githubusercontent.com/omotenashicoin-project/OmotenashiCoin-HDwalletbinaries/03bde4168217f8d8afe421f5c3fe9970a84d7ab5/stable/omotenashicoin-x86_64-linux-gnu.tar.gz", + "verification_type": "sha256", + "verification_source": "1275cd19715ad4e255b92d782cf969314174505eaeb5b26ff6e3b9d40d6b3815", + "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/testnet4/*.log", + "postinst_script_template": "", + "service_type": "forking", + "service_additional_params_template": "", + "protect_memory": true, + "mainnet": false, + "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-testnet", + "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": 70544129, + "slip44": 1 + } + }, + "meta": { + "package_maintainer": "omotenashicoin dev", + "package_maintainer_email": "git@omotenashicoin.site" + } } 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..ec273cbe7c --- /dev/null +++ b/configs/coins/optimism_archive.json @@ -0,0 +1,83 @@ +{ + "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, + "missingBlockRetry": { + "retryDelayMs": 1000, + "recheckThreshold": 10, + "tipRecheckThreshold": 3, + "maxStallMs": 60000 + }, + "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": "10s", + "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..a7e20ef7e5 --- /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/8205f678b7b4ac4625c2afe351b9c82ffaa2e795.zip", + "verification_type": "sha256", + "verification_source": "fdad65222b361440dd68909eea44a1f0eff6093f6c3a7586747d25d571b870a7", + "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-8205f678b7b4ac4625c2afe351b9c82ffaa2e795/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..89eef8fe6f --- /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\": \"polygon-ecosystem-token\",\"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..de424c42e9 --- /dev/null +++ b/configs/coins/polygon_archive.json @@ -0,0 +1,89 @@ +{ + "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, + "missingBlockRetry": { + "retryDelayMs": 1000, + "recheckThreshold": 10, + "tipRecheckThreshold": 3, + "maxStallMs": 60000 + }, + "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": "10s", + "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\": \"polygon-ecosystem-token\",\"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 95609e66a1..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": "0.20.1", - "binary_url": "https://github.com/qtumproject/qtum/releases/download/mainnet-ignition-v0.20.1/qtum-0.20.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": "e9a77eb3e2b76625fdc8058c12bc0790309b24b9c1ac39f4895e4f8756cf0010", + "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 507c1503b0..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": "0.20.1", - "binary_url": "https://github.com/qtumproject/qtum/releases/download/mainnet-ignition-v0.20.1/qtum-0.20.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": "e9a77eb3e2b76625fdc8058c12bc0790309b24b9c1ac39f4895e4f8756cf0010", + "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/syscoin.json b/configs/coins/syscoin.json index f95941704d..d0ca2aaae5 100644 --- a/configs/coins/syscoin.json +++ b/configs/coins/syscoin.json @@ -22,10 +22,10 @@ "package_name": "backend-syscoin", "package_revision": "satoshilabs-1", "system_user": "syscoin", - "version": "5.0.0.0", - "binary_url": "https://github.com/syscoin/syscoin/releases/download/v5.0.0/syscoin-5.0.0-x86_64-linux-gnu.tar.gz", + "version": "5.1.0.0", + "binary_url": "https://github.com/syscoin/syscoin/releases/download/v5.1.0/syscoin-5.1.0-x86_64-linux-gnu.tar.gz", "verification_type": "gpg-sha256", - "verification_source": "https://github.com/syscoin/syscoin/releases/download/v5.0.0/SHA256SUMS.asc", + "verification_source": "https://github.com/syscoin/syscoin/releases/download/v5.1.0/SHA256SUMS.asc", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/syscoin-qt" @@ -40,11 +40,22 @@ "server_config_file": "syscoin.conf", "client_config_file": "bitcoin_client.conf", "additional_params": { - "deprecatedrpc": "estimatefee" + "deprecatedrpc": "estimatefee", + "gethcommandline": [ + "--http", + "--http.addr", + "127.0.0.1", + "--http.port", + "28545", + "--http.api", + "eth,net,web3", + "--port", + "30303" + ] }, "platforms": { "arm64": { - "binary_url": "https://github.com/syscoin/syscoin/releases/download/v5.0.0/syscoin-5.0.0-aarch64-linux-gnu.tar.gz" + "binary_url": "https://github.com/syscoin/syscoin/releases/download/v5.1.0/syscoin-5.1.0-aarch64-linux-gnu.tar.gz" } } }, @@ -54,7 +65,7 @@ "internal_binding_template": ":{{.Ports.BlockbookInternal}}", "public_binding_template": ":{{.Ports.BlockbookPublic}}", "explorer_url": "", - "additional_params": "-dbcache=1073741824", + "additional_params": "-dbcache=4294967296 -dbmaxopenfiles=500000 -workers=16 -chunk=1000", "block_chain": { "parse": true, "mempool_workers": 8, @@ -64,10 +75,10 @@ "xpub_magic_segwit_p2sh": 77429938, "xpub_magic_segwit_native": 78792518, "slip44": 57, - "web3_rpc_url": "https://rpc.syscoin.org", - "web3_rpc_url_backup": "https://rpc1.syscoin.org", + "web3_rpc_url": "http://127.0.0.1:28545", + "web3_rpc_url_backup": "", "web3_explorer_url": "https://explorer.syscoin.org", - "subversion": "/Satoshi:5.0.0/", + "subversion": "/Satoshi:5.1.0/", "additional_params": { "fiat_rates": "coingecko", "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"syscoin\", \"periodSeconds\": 150}" diff --git a/configs/coins/syscoin_testnet.json b/configs/coins/syscoin_testnet.json index 853bbcfe42..77d186ac84 100644 --- a/configs/coins/syscoin_testnet.json +++ b/configs/coins/syscoin_testnet.json @@ -6,10 +6,10 @@ "alias": "syscoin_testnet" }, "ports": { - "backend_rpc": 18035, - "backend_message_queue": 48335, - "blockbook_internal": 19135, - "blockbook_public": 19035 + "backend_rpc": 18067, + "backend_message_queue": 48367, + "blockbook_internal": 19067, + "blockbook_public": 19167 }, "ipc": { "rpc_url_template": "http://127.0.0.1:{{.Ports.BackendRPC}}", @@ -22,8 +22,10 @@ "package_name": "backend-syscoin-testnet", "package_revision": "satoshilabs-1", "system_user": "syscoin", - "version": "5.0.99.0", - "binary_url": "https://github.com/syscoin/syscoin/releases/download/v5.0.99/syscoin-5.0.99-x86_64-linux-gnu.tar.gz", + "version": "5.1.0.0", + "binary_url": "https://github.com/syscoin/syscoin/releases/download/v5.1.0/syscoin-5.1.0-x86_64-linux-gnu.tar.gz", + "verification_type": "gpg-sha256", + "verification_source": "https://github.com/syscoin/syscoin/releases/download/v5.1.0/SHA256SUMS.asc", "extract_command": "tar -C backend --strip 1 -xf", "exclude_files": [ "bin/syscoin-qt" @@ -38,14 +40,25 @@ "server_config_file": "syscoin.conf", "client_config_file": "bitcoin_client.conf", "additional_params": { - "deprecatedrpc": "estimatefee" + "deprecatedrpc": "estimatefee", + "gethcommandline": [ + "--http", + "--http.addr", + "127.0.0.1", + "--http.port", + "18545", + "--http.api", + "eth,net,web3", + "--port", + "30304" + ] }, "platforms": { "arm64": { - "binary_url": "https://github.com/syscoin/syscoin/releases/download/v5.0.99/syscoin-5.0.99-aarch64-linux-gnu.tar.gz" + "binary_url": "https://github.com/syscoin/syscoin/releases/download/v5.1.0/syscoin-5.1.0-aarch64-linux-gnu.tar.gz" } } - + }, "blockbook": { "package_name": "blockbook-syscoin-testnet", @@ -53,7 +66,7 @@ "internal_binding_template": ":{{.Ports.BlockbookInternal}}", "public_binding_template": ":{{.Ports.BlockbookPublic}}", "explorer_url": "", - "additional_params": "", + "additional_params": "-dbcache=4294967296 -dbmaxopenfiles=500000 -workers=16 -chunk=1000", "block_chain": { "parse": true, "mempool_workers": 8, @@ -63,10 +76,10 @@ "xpub_magic_segwit_p2sh": 71979618, "xpub_magic_segwit_native": 73342198, "slip44": 1, - "web3_rpc_url": "https://rpc.tanenbaum.io", - "web3_rpc_url_backup": "https://rpc1.tanenbaum.io", + "web3_rpc_url": "http://127.0.0.1:18545", + "web3_rpc_url_backup": "", "web3_explorer_url": "https://explorer.tanenbaum.io", - "subversion": "/Satoshi:5.0.99/", + "subversion": "/Satoshi:5.1.0/", "additional_params": { } } diff --git a/configs/coins/trezarcoin.json b/configs/coins/trezarcoin.json index c0b7ec3bb1..11de15844b 100644 --- a/configs/coins/trezarcoin.json +++ b/configs/coins/trezarcoin.json @@ -1,70 +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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"trezarcoin\", \"periodSeconds\": 60}" - } - } - }, - "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..0999457da1 --- /dev/null +++ b/configs/coins/tron.json @@ -0,0 +1,74 @@ +{ + "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/6e57175bcd3ffff69b4505c58c6c7da4a88cbd2b/main_net_config.conf -O main_net_config.conf && sha256sum main_net_config.conf | grep -q '^d3bba4df255cedcbab850a5f2a65847f9075a96362c0bf9a345c0943bdc2b04d' && 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, + "missingBlockRetry": { + "retryDelayMs": 1000, + "recheckThreshold": 10, + "tipRecheckThreshold": 3, + "maxStallMs": 60000 + }, + "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..35739d1a3e --- /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/3f6f53ca3b1f113dabd7bcba346d66b1fb71b710/framework/src/main/resources/config-nile.conf -O test_net_config.conf && sha256sum test_net_config.conf | grep -q '^296455a150477bc0f80a4125a57809cafcd843c271f1d7973ee051576a827b84' && 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 fbb287d13e..074f377edb 100644 --- a/configs/coins/vertcoin.json +++ b/configs/coins/vertcoin.json @@ -1,70 +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": "0.15.0.1", - "binary_url": "https://github.com/vertcoin-project/vertcoin-core/releases/download/0.15.0.1/vertcoind-v0.15.0.1-linux-amd64.zip", - "verification_type": "gpg", - "verification_source": "https://github.com/vertcoin-project/vertcoin-core/releases/download/0.15.0.1/vertcoind-v0.15.0.1-linux-amd64.zip.sig", - "extract_command": "unzip -d backend", - "exclude_files": [], - "exec_command_template": "{{.Env.BackendInstallPath}}/{{.Coin.Alias}}/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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"vertcoin\", \"periodSeconds\": 60}" - } - } - }, - "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 54f32bcb0b..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,12 +23,14 @@ "package_name": "backend-vertcoin-testnet", "package_revision": "satoshilabs-1", "system_user": "vertcoin", - "version": "0.15.0.1", - "binary_url": "https://github.com/vertcoin-project/vertcoin-core/releases/download/0.15.0.1/vertcoind-v0.15.0.1-linux-amd64.zip", - "verification_type": "gpg", - "verification_source": "https://github.com/vertcoin-project/vertcoin-core/releases/download/0.15.0.1/vertcoind-v0.15.0.1-linux-amd64.zip.sig", - "extract_command": "unzip -d backend", - "exclude_files": [], + "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}}/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/testnet3/*.log", "postinst_script_template": "", 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 15a2a47407..e97929860f 100644 --- a/configs/coins/zcash.json +++ b/configs/coins/zcash.json @@ -1,70 +1,66 @@ { - "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": "4.1.1", - "binary_url": "https://z.cash/downloads/zcash-4.1.1-linux64-debian-stretch.tar.gz", - "verification_type": "sha256", - "verification_source": "50b639f0d1c7177809535bad7631490297aa7873d867425096eb8c7a04b2b132", - "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, + "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 + }, + "backend": { + "package_name": "backend-zcash", + "package_revision": "satoshilabs-1", + "system_user": "zcash", + "version": "5.0.0", + "docker_image": "zfnd/zebra:5.0.0", + "verification_type": "docker", + "verification_source": "cfcdc088977ccd85562a60911845b24d962317c93d861b12aa1b98813f9f2374", + "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": { + "averageBlockTimeMs": 75000, + "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_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"zcash\", \"periodSeconds\": 60}" - } - } - }, - "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 e593a0d0ab..f4c99ce127 100644 --- a/configs/coins/zcash_testnet.json +++ b/configs/coins/zcash_testnet.json @@ -1,67 +1,61 @@ { - "coin": { - "name": "Zcash Testnet", - "shortcut": "TAZ", - "label": "Zcash Testnet", - "alias": "zcash_testnet" - }, - "ports": { - "backend_rpc": 18032, - "backend_message_queue": 48332, - "blockbook_internal": 19032, - "blockbook_public": 19132 - }, - "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-testnet", - "package_revision": "satoshilabs-1", - "system_user": "zcash", - "version": "4.1.1", - "binary_url": "https://z.cash/downloads/zcash-4.1.1-linux64-debian-stretch.tar.gz", - "verification_type": "sha256", - "verification_source": "50b639f0d1c7177809535bad7631490297aa7873d867425096eb8c7a04b2b132", - "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/testnet3/*.log", - "postinst_script_template": "HOME={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend {{.Env.BackendInstallPath}}/{{.Coin.Alias}}/bin/zcash-fetch-params --testnet", - "service_type": "forking", - "service_additional_params_template": "Environment=\"HOME={{.Env.BackendDataPath}}/{{.Coin.Alias}}/backend\"", - "protect_memory": false, - "mainnet": false, - "server_config_file": "bitcoin_like.conf", - "client_config_file": "bitcoin_like_client.conf", - "additional_params": { - "addnode": [ - "testnet.z.cash" - ] + "coin": { + "name": "Zcash Testnet", + "shortcut": "TAZ", + "label": "Zcash Testnet", + "alias": "zcash_testnet" + }, + "ports": { + "backend_rpc": 18032, + "blockbook_internal": 19032, + "blockbook_public": 19132 + }, + "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 + }, + "backend": { + "package_name": "backend-zcash-testnet", + "package_revision": "satoshilabs-1", + "system_user": "zcash", + "version": "5.0.0", + "docker_image": "zfnd/zebra:5.0.0", + "verification_type": "docker", + "verification_source": "cfcdc088977ccd85562a60911845b24d962317c93d861b12aa1b98813f9f2374", + "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_testnet.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": false, + "server_config_file": "zcash_testnet.conf", + "client_config_file": "bitcoin_like_client.conf" + }, + "blockbook": { + "package_name": "blockbook-zcash-testnet", + "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": 70617039, + "slip44": 1, + "additional_params": {} + } + }, + "meta": { + "package_maintainer": "IT Admin", + "package_maintainer_email": "it@satoshilabs.com" } - }, - "blockbook": { - "package_name": "blockbook-zcash-testnet", - "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": 70617039, - "slip44": 1, - "additional_params": {} - } - }, - "meta": { - "package_maintainer": "IT Admin", - "package_maintainer_email": "it@satoshilabs.com" - } -} \ No newline at end of file +} 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 4554561a8d..b1e38d6012 100644 --- a/configs/environ.json +++ b/configs/environ.json @@ -1,5 +1,5 @@ { - "version": "0.3.6", + "version": "0.6.0", "backend_install_path": "/opt/coins/nodes", "backend_data_path": "/opt/coins/data", "blockbook_install_path": "/opt/coins/blockbook", diff --git a/configs/grafana/README.md b/configs/grafana/README.md new file mode 100644 index 0000000000..21ee1ae32d --- /dev/null +++ b/configs/grafana/README.md @@ -0,0 +1,59 @@ +# Metrics & Grafana — single source of truth + +Blockbook's prometheus metrics and its Grafana dashboard are generated from a few +source files, so metric names/help and panel queries/descriptions are never hand-synced. + +```mermaid +flowchart TD + M["configs/metrics.yaml
name · type · help · labels · buckets"] + T["configs/grafana/template.json
viz skeleton + x-panel-key / x-query-key"] + P["configs/grafana/panels.yaml
per x-panel-key: title · description · queries · width/height"] + G["common.GetMetrics
builds + registers collectors at startup"] + R(["contrib/scripts/render_grafana.py"]) + D["configs/grafana/grafana.json
import into Grafana — generated, git-ignored"] + M -->|go:embed| G + M --> R + T --> R + P --> R + R --> D +``` + +## Files + +| file | holds | committed | +|---|---|---| +| `../metrics.yaml` | every metric, keyed by a **stable id**: `name`, `type`, `help`, `labels`, `buckets` | yes | +| `template.json` | dashboard **skeleton** — rows, panel type, `fieldConfig`, `options`, plus a semantic `x-panel-key` per panel and `x-query-key` per target (the join keys). No titles/descriptions/exprs/legends, no `gridPos`, no `datasource`. | yes | +| `panels.yaml` | per-panel **content**, keyed by `x-panel-key` (e.g. `rpc.request_rate`): `title`, `description`, `queries` keyed by `x-query-key` (each with `promql` + `legend`), and optional `width`/`height` (default `8`×`8`; rows fill the row) | yes | +| `grafana.json` | the rendered dashboard you import into Grafana | **no** (git-ignored) | + +`render_grafana.py` packs panels into Grafana's 24-column grid from template order and each panel's +`width`/`height` (panels.yaml), so the committed template carries no brittle `x/y` positions; it also +injects the single Prometheus `datasource` onto every panel and target, so the template repeats none. +It joins each `panels.yaml` entry to its template panel by `x-panel-key`, and each `queries:` entry to +a template target by `x-query-key` (Grafana's own `id`/`refId` stay in the template; the x-keys are +stripped from the rendered `grafana.json`). Inside `promql` / `description`, `{{name:}}` / +`{{help:}}` expand from `../metrics.yaml`, so a metric's name lives in one place and a rename +propagates to the Go binary and every panel. + +Use stable, descriptive keys: `x-panel-key` should look like `
.[_stat]` +(for example `rpc.request_duration_p95`), and `x-query-key` should name the plotted series +(`requests`, `errors`, `p95`, `total`, `threshold`). Rename titles freely, but keep these keys +stable once other files refer to them. + +## Render + +```bash +python3 contrib/scripts/render_grafana.py # write configs/grafana/grafana.json +python3 contrib/scripts/render_grafana.py --check # validate alignment only, no write (CI) +``` + +`--check` fails on an unknown metric key, an invalid `width`/`height`, a `gridPos` or `datasource` +that leaked into `template.json`, a template ↔ `panels.yaml` `x-panel-key` or `x-query-key` mismatch, +a leftover placeholder, or any per-panel title/description/expr/legend that leaked into `template.json`. +It also re-checks the **rendered** dashboard for the invariants Grafana enforces only at import time +(unique panel ids, every panel inside the 24-column grid, no overlaps, a datasource on every panel and +target, every `${input}` declared) so a structural defect fails the render here, not silently in Grafana. + +> How to add or rename a metric or panel: see the **Metrics** section in `AGENTS.md`. +> The Grafana UI is preview-only — `template.json` + `panels.yaml` are the source. diff --git a/configs/grafana/panels.yaml b/configs/grafana/panels.yaml new file mode 100644 index 0000000000..9315604ccf --- /dev/null +++ b/configs/grafana/panels.yaml @@ -0,0 +1,699 @@ +panels: + row.system: + title: System + system.resident_memory: + title: Resident memory bytes + description: Includes Go heap, native allocations and mmap-backed pages; compare with Go heap to spot non-Go memory growth. + queries: + resident: + promql: process_resident_memory_bytes{job="blockbook"} + legend: '{{instance}}' + system.virtual_memory: + title: Virtual memory bytes + description: Address space reserved by the process; high values are not necessarily resident RAM. + queries: + virtual: + promql: process_virtual_memory_bytes{job="blockbook"} + legend: vm-{{instance}} + system.go_alloc: + title: Go heap allocated bytes + description: Go runtime heap in use; compare with resident memory to separate heap growth from native or mmap growth. + queries: + allocated: + promql: go_memstats_alloc_bytes{job="blockbook"} + legend: '{{instance}}' + system.open_fds: + title: Open file descriptors + description: Sustained growth can indicate leaked sockets or files. + queries: + fds: + promql: process_open_fds{job="blockbook"} + legend: '{{instance}}' + system.node_load1: + title: Node load (1 minute) + description: Host-level load, not Blockbook-only; compare across instances before blaming one process. + queries: + load: + promql: node_load1 + legend: '{{instance}}' + system.node_mem_available: + title: Node memory available + description: Host-level memory pressure signal from node_exporter. + queries: + available: + promql: node_memory_MemAvailable_bytes + legend: '{{instance}}' + row.rpc: + title: RPC + rpc.request_rate: + title: RPC request rate (per minute) + description: Calls Blockbook sends to coin backends, so increases here reflect backend load rather than public API traffic. + queries: + requests: + promql: sum(rate({{name:rpc_latency}}_count{job="blockbook", coin="$coin"}[1m])) by (coin, method) * 60 + legend: '{{coin}} - {{method}}' + rpc.request_duration_p95: + title: 95% quantile of RPC request duration + description: Blockbook-to-backend round-trip time; high method-specific latency slows sync and request handlers using that backend method. + queries: + p95: + promql: histogram_quantile(0.95, sum(rate({{name:rpc_latency}}_bucket{job="blockbook", coin="$coin"}[1m])) by (coin, method, le)) + legend: '{{coin}} - {{method}}' + rpc.fallback_calls: + title: RPC fallback calls per minute + description: Non-primary chain-data paths used when the preferred lookup cannot answer; sustained growth means normal RPC/index assumptions are being bypassed. + queries: + fallbacks: + promql: sum(rate({{name:rpc_fallback_calls_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (component, reason) * 60 + legend: '{{component}} - {{reason}}' + rpc.eth_call_requests: + title: eth_call requests + description: Contract metadata, NFT URI, staking-pool and other EVM read paths; spikes translate into backend read load. + queries: + requests: + promql: rate({{name:eth_call_requests}}{job="blockbook", coin="$coin"}[5m]) + legend: '{{instance}} {{mode}}/s' + rpc.eth_call_errors: + title: eth_call errors + description: Separates failures by call path and error class, useful for spotting provider limits or bad contract responses. + queries: + errors: + promql: rate({{name:eth_call_errors}}{job="blockbook", coin="$coin"}[5m]) * 60 + legend: '{{instance}} {{mode}} {{type}}/min' + rpc.eth_call_batch_size: + title: eth_call batch size (avg) + description: Higher values mean more EVM reads are being coalesced into fewer backend requests. + queries: + avg: + promql: rate({{name:eth_call_batch_size}}_sum{job="blockbook", coin="$coin"}[5m]) / rate({{name:eth_call_batch_size}}_count{job="blockbook", coin="$coin"}[5m]) + legend: '{{instance}} avg' + rpc.eth_call_token_uri: + title: eth_call token URI requests + description: NFT metadata enrichment path; spikes often follow NFT-heavy address or transaction requests. + queries: + requests: + promql: rate({{name:eth_call_token_uri_requests}}{job="blockbook", coin="$coin"}[5m]) + legend: '{{instance}} {{method}}/s' + rpc.eth_call_staking_pool: + title: Staking pool field read calls + description: Used by staking-pool enrichment; field labels show which contract property is driving backend load. + queries: + requests: + promql: rate({{name:eth_call_staking_pool_requests}}{job="blockbook", coin="$coin"}[5m]) * 60 + legend: '{{instance}} {{field}}/min' + rpc.eth_call_contract_info: + title: eth_call contract info requests + description: Contract metadata lookups such as name, symbol and decimals, split by requested field. + queries: + requests: + promql: rate({{name:eth_call_contract_info_requests}}{job="blockbook", coin="$coin"}[5m]) * 60 + legend: '{{instance}} {{field}}/min' + rpc.eth_sync_errors: + title: Ethereum sync RPC errors per minute + description: Sync-path EVM backend failures only; timeout and HTTP-class labels help separate provider slowness from RPC errors. + queries: + errors: + promql: sum(rate({{name:eth_sync_rpc_errors}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (method, status) * 60 + legend: '{{method}} - {{status}}' + row.sync: + title: Sync + sync.index_resync_duration_p95: + title: 95% quantile of index resync duration + description: High values mean resync rounds are taking longer before returning to the outer sync loop. + queries: + p95: + promql: histogram_quantile(0.95, sum(rate({{name:index_resync_duration}}_bucket{job="blockbook", coin="$coin"}[1m])) by (coin, le)) + legend: '{{coin}}' + sync.index_resync_errors: + title: Index resync errors per minute + description: Counts index resync failures, including retryable per-block misses that may still recover within the same iteration. + queries: + errors: + promql: sum(rate({{name:index_resync_errors}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (error) * 60 + legend: '{{error}}' + sync.index_block_not_found_retries: + title: Index block-not-found retries per minute + description: Usually points to backend routing skew or Blockbook asking for a block before every backend node can serve it. + queries: + retries: + promql: sum(rate({{name:index_block_not_found_retries}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (instance) * 60 + legend: '{{coin}} - {{instance}}' + sync.index_reorg_events: + title: Index reorg events + description: Detection path distinguishes in-stream prevHash mismatches, resync-triggered missing hashes and local rollback disconnects. + queries: + events: + promql: sum(increase({{name:index_reorg_events}}{job="blockbook", coin="$coin"}[$__interval])) by (type) + legend: '{{type}}' + sync.index_sync_yields: + title: Index sync yields + description: The retry loop gave control back to outer resync because a deadline elapsed or chain-state probes kept failing. + queries: + yields: + promql: sum(increase({{name:index_sync_yields}}{job="blockbook", coin="$coin"}[$__interval])) by (reason) + legend: '{{reason}}' + sync.block_stats_tip: + title: Sync block stats (tip) + description: Shows what each newly connected tip block contained; UTXO chains expose vin/vout and EVM chains expose token/internal transfers. + queries: + stats: + promql: avg_over_time({{name:sync_block_stats}}{job="blockbook", coin="$coin", scope="tip"}[5m]) + legend: '{{instance}} {{kind}}' + sync.block_stats_bulk: + title: Sync block stats (bulk interval) + description: Bulk-sync interval totals; useful for comparing batch shape and throughput across chain families. + queries: + stats: + promql: '{{name:sync_block_stats}}{job="blockbook", coin="$coin", scope="bulk"}' + legend: '{{instance}} {{kind}}' + sync.mempool_size: + title: Mempool size + description: Populated from the last successful backend mempool reload; sudden drops may reflect backend data loss, not an empty network mempool. + queries: + size: + promql: '{{name:mempool_size}}{job="blockbook", coin="$coin"}' + legend: '{{coin}} - {{instance}}' + sync.eth_alternative_mempool_reconciliation: + title: Alternative mempool reconciliation + description: Decisions over the alternative-provider-only send-tx cache lifecycle (events/min by action). mined, nonce_superseded, provider_missing, timeout (reconcile loop or read-path expiry) and rbf_replaced remove an entry; provider_error and provider_missing_pending keep an unconfirmed entry for a later cycle (a private/MEV relay no longer surfacing a still-mineable tx); skipped_fresh and kept retain a still-young or still-pending entry. + queries: + actions: + promql: sum(rate({{name:eth_alternative_mempool_reconciliation_events_total}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (action) * 60 + legend: '{{action}}/min' + sync.eth_alternative_mempool_cache_size: + title: Alternative mempool cache depth + description: Transactions currently held in the alternative-provider-only send-tx cache, sampled at the end of each reconcile cycle. It should track the number of in-flight unconfirmed sends; a steady climb means entries are added faster than they are mined or evicted. + queries: + size: + promql: '{{name:eth_alternative_mempool_cache_size}}{job="blockbook", coin="$coin"}' + legend: '{{coin}} - {{instance}}' + sync.eth_alternative_mempool_tx_residence: + title: Alternative mempool tx lifetime by eviction reason + description: p50/p95 of how long an alternative send-tx cache entry survives before each eviction reason fires. provider_missing and timeout sitting near the cache timeout is healthy; provider_missing collapsing toward ~1-2 min would surface the premature-eviction regression from #1573, while mined shows how quickly relays actually confirm. + queries: + p50: + promql: histogram_quantile(0.5, sum(rate({{name:eth_alternative_mempool_tx_residence_seconds}}_bucket{job="blockbook", coin="$coin"}[$__rate_interval])) by (action, le)) + legend: '{{action}} p50' + p95: + promql: histogram_quantile(0.95, sum(rate({{name:eth_alternative_mempool_tx_residence_seconds}}_bucket{job="blockbook", coin="$coin"}[$__rate_interval])) by (action, le)) + legend: '{{action}} p95' + sync.mempool_resync_throughput_p95: + title: 95% quantile of mempool resync throughput + description: Transactions returned by a mempool reload divided by reload duration; low values mean the backend or parser is feeding the mempool slowly. + queries: + p95: + promql: histogram_quantile(0.95, sum(rate({{name:mempool_resync_throughput_txs_per_second}}_bucket{job="blockbook", coin="$coin"}[1m])) by (coin, chain, status, le)) + legend: '{{coin}} - {{chain}} - {{status}}' + sync.mempool_resync_duration_p95: + title: 95% quantile of mempool resync duration + description: Large values can point to slow backend mempool reads or expensive mempool processing. + queries: + p95: + promql: histogram_quantile(0.95, sum(rate({{name:mempool_resync_duration}}_bucket{job="blockbook", coin="$coin"}[5m])) by (coin, le)) + legend: '{{coin}}' + sync.hotness_stats_tip: + title: Hotness stats (tip) + description: 'EVM hot-address cache behavior while connecting tip blocks: eligible lookups, LRU hits, promotions and evictions.' + queries: + stats: + promql: '{{name:sync_hotness_stats}}{job="blockbook", coin="$coin", scope="tip"}' + legend: '{{instance}} {{kind}}' + sync.hotness_stats_bulk: + title: Hotness stats (bulk interval) + description: EVM hot-address cache behavior during bulk sync intervals, where poor hit rates can amplify RocksDB reads. + queries: + stats: + promql: '{{name:sync_hotness_stats}}{job="blockbook", coin="$coin", scope="bulk"}' + legend: '{{instance}} {{kind}}' + sync.addr_contracts_cache_entries: + title: AddrContracts cache entries + description: Entry count for the Ethereum-type addressContracts cache; sudden churn with low hit rates points to capacity pressure. + queries: + entries: + promql: '{{name:addr_contracts_cache_entries}}{job="blockbook", coin="$coin"}' + legend: '{{instance}}' + sync.addr_contracts_cache_bytes: + title: AddrContracts cache bytes + description: Memory footprint estimate for capacity planning; compare with resident memory when tuning the cache. + queries: + bytes: + promql: '{{name:addr_contracts_cache_bytes}}{job="blockbook", coin="$coin"}' + legend: '{{instance}}' + sync.addr_contracts_cache_activity: + title: AddrContracts cache activity + description: Hits avoid RocksDB reads, misses fall through to storage, and flushes show cap- or timer-driven cache resets. + queries: + hits: + promql: rate({{name:addr_contracts_cache_hits_total}}{job="blockbook", coin="$coin"}[5m]) * 60 + legend: '{{instance}} hits/min' + misses: + promql: rate({{name:addr_contracts_cache_misses_total}}{job="blockbook", coin="$coin"}[5m]) * 60 + legend: '{{instance}} misses/min' + flushes: + promql: rate({{name:addr_contracts_cache_flush_total}}{job="blockbook", coin="$coin"}[1h]) * 60 + legend: '{{instance}} flushes {{reason}}/min' + sync.tip_feed_age: + title: Backend tip feed age + description: Measures notification silence for EVM newHeads or Tron ZeroMQ; healthy is low and flat, while sawtooth near the threshold means watchdog polling is carrying sync. + queries: + age: + promql: '{{name:backend_subscription_age_seconds}}{job="blockbook", coin="$coin"}' + legend: '{{coin}} - {{instance}}' + threshold: + promql: clamp(30 * {{name:average_block_time_seconds}}{job="blockbook", coin="$coin"}, 30, 300) + legend: '{{coin}} stall threshold' + sync.tip_feed_watchdog: + title: Tip feed watchdog interventions /s + description: watchdog_stall = feed went silent past the threshold; watchdog_tip_advanced = the fallback poll found blocks the feed missed (strongest signal the push feed is broken). + queries: + stall: + promql: sum by (coin) (rate({{name:backend_subscription_events}}{job="blockbook", coin="$coin", event="watchdog_stall"}[5m])) + legend: '{{coin}} watchdog_stall' + tip_advanced: + promql: sum by (coin) (rate({{name:backend_subscription_events}}{job="blockbook", coin="$coin", event="watchdog_tip_advanced"}[5m])) + legend: '{{coin}} watchdog_tip_advanced' + sync.tip_feed_reconnect_failures: + title: Tip feed reconnect / resubscribe failures /s + description: Sustained non-zero values usually mean the backend notification transport cannot recover after the feed stalls. + queries: + failures: + promql: sum by (coin) (rate({{name:backend_subscription_events}}{job="blockbook", coin="$coin", event=~"watchdog_reconnect_failed|resubscribe_failed"}[5m])) + legend: '{{coin}} failures' + sync.tip_feed_events: + title: Tip feed lifecycle rate + description: Use event labels to distinguish subscription errors, resubscribe cycles and watchdog heartbeats; watchdog_tick rate == 0 while up means the watchdog stopped ticking. + queries: + events: + promql: sum by (subscription, event) (rate({{name:backend_subscription_events}}{job="blockbook", coin="$coin"}[5m])) + legend: '{{subscription}} - {{event}}' + row.websocket: + title: Websocket + websocket.clients: + title: Websocket connected clients + description: Tracks Suite fan-out and connection pressure against high-connection-count deployments. + queries: + clients: + promql: '{{name:websocket_clients}}{job="blockbook", coin="$coin"}' + legend: '{{coin}} - {{instance}}' + websocket.request_rate: + title: Websocket requests per minute + description: Method and status labels separate normal client traffic from failing or unsupported websocket calls. + queries: + requests: + promql: sum(rate({{name:websocket_requests}}{job="blockbook", coin="$coin"}[1m])) by (coin, method, status) * 60 + legend: '{{coin}} - {{method}} - {{status}}' + websocket.subscriptions: + title: Websocket subscriptions + description: Long-lived fan-out by method; newBlockTxs is tracked separately because it triggers per-block address matching. + queries: + subscribes: + promql: '{{name:websocket_subscribes}}{job="blockbook", coin="$coin"}' + legend: Websocket {{method}} - {{instance}} + new_block_txs: + promql: '{{name:websocket_new_block_txs_subscriptions}}{job="blockbook", coin="$coin"}' + legend: newBlockTxs subscriptions - {{instance}} + websocket.new_block_notifications: + title: Websocket new block notifications per minute + description: Rate of new-block notifications delivered to subscribeNewBlock subscribers (each indexed block fans out to every subscribed client), replacing the former per-block "broadcasting new block" log line. This is block production times the subscriber count, so it stays at zero with no subscribers and scales with fan-out; a flatline while the chain advances and subscribers are connected means the broadcast path has stalled. Read alongside the subscriptions panel for the current subscriber count. + queries: + notifications: + promql: sum(rate({{name:websocket_new_block_notifications}}{job="blockbook", coin="$coin"}[1m])) by (coin) * 60 + legend: '{{coin}} - notif/min' + websocket.request_duration_p95: + title: 95% quantile of websocket request duration + description: Handler latency after a websocket request is accepted; large values can hold a pending-request slot longer. + queries: + p95: + promql: histogram_quantile(0.95, sum(rate({{name:websocket_req_duration}}_bucket{job="blockbook", coin="$coin"}[1m])) by (coin, method, le)) + legend: '{{coin}} - {{method}}' + websocket.new_block_txs_rate: + title: Websocket newBlockTxs events per minute + description: Counts the match, convert and publish stages used to notify address subscribers about confirmed block transactions. + queries: + events: + promql: sum(rate({{name:websocket_new_block_txs}}{job="blockbook", coin="$coin"}[1m])) by (coin, stage, status) * 60 + legend: '{{coin}} - {{stage}} - {{status}}' + websocket.new_block_txs_duration_p95: + title: 95% quantile of websocket newBlockTxs stage duration + description: Stage timing for per-block address matching and transaction conversion before websocket publication. + queries: + p95: + promql: histogram_quantile(0.95, sum(rate({{name:websocket_new_block_txs_duration_seconds}}_bucket{job="blockbook", coin="$coin"}[1m])) by (coin, stage, le)) + legend: '{{coin}} - {{stage}}' + websocket.addr_notifications: + title: Websocket address notifications per minute + description: Source label distinguishes mempool notifications from confirmed-block newBlockTxs notifications. + queries: + notifications: + promql: sum(rate({{name:websocket_addr_notifications}}{job="blockbook", coin="$coin"}[1m])) by (coin, source) * 60 + legend: '{{coin}} - {{source}}' + websocket.channel_closes_unknown: + title: Websocket channel closes / unknown methods per minute + description: Combines connection-close reasons with requests rejected before dispatch because the method name was unsupported. + queries: + closes: + promql: sum(rate({{name:websocket_channel_closes}}{job="blockbook", coin="$coin"}[1m])) by (coin, reason) * 60 + legend: closes {{reason}} - {{coin}} + unknown: + promql: sum(rate({{name:websocket_unknown_methods}}{job="blockbook", coin="$coin"}[1m])) by (coin, method) * 60 + legend: unknown {{method}} - {{coin}} + websocket.eth_receipt: + title: Websocket eth receipt outcomes + description: Tracks best-effort receipt loading for EVM newBlockTxs notifications; errors do not stop publication. + queries: + outcomes: + promql: sum(rate({{name:websocket_eth_receipt}}{job="blockbook", coin="$coin"}[5m])) by (coin, status) * 60 + legend: '{{coin}} {{status}}/min' + websocket.connection_requests: + title: Websocket requests per connection + description: Distribution of how many requests each connection handled before it closed; a p99 that towers over p50 flags a single connection flooding messages, the signal for whether per-connection message rate limiting is worth adding. Recorded when a connection closes, so long-lived sockets only show up at disconnect. + queries: + p50: + promql: histogram_quantile(0.50, sum(rate({{name:websocket_connection_requests}}_bucket{job="blockbook", coin="$coin"}[$__rate_interval])) by (coin, le)) + legend: '{{coin}} - p50' + p99: + promql: histogram_quantile(0.99, sum(rate({{name:websocket_connection_requests}}_bucket{job="blockbook", coin="$coin"}[$__rate_interval])) by (coin, le)) + legend: '{{coin}} - p99' + websocket.connection_rejections: + title: Websocket connection rejections per minute + description: 'Connection attempts the per-IP limiter rejected before the websocket upgrade (HTTP 429): connection_limit is more than 128 concurrent connections from one IP, connection_attempt_limit is more than 64 attempts/minute. Sustained non-zero values mean connection-flood abuse is being actively blocked.' + queries: + rejections: + promql: sum(rate({{name:websocket_connection_rejections}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (coin, reason) * 60 + legend: '{{coin}} - {{reason}}/min' + websocket.unique_ips: + title: Websocket connections vs unique client IPs + description: Open connections plotted against the number of distinct client IPs holding them; the gap between the two lines is connection clustering. Across the many Suite users a healthy deployment serves, the lines should track closely at roughly one connection per IP. A connections line pulling well above unique IPs means connections are concentrated on few sources. + queries: + connections: + promql: sum({{name:websocket_clients}}{job="blockbook", coin="$coin"}) by (coin) + legend: '{{coin}} - connections' + unique_ips: + promql: sum({{name:websocket_unique_ips}}{job="blockbook", coin="$coin"}) by (coin) + legend: '{{coin}} - unique IPs' + websocket.connections_per_ip: + title: Websocket connections per client IP + description: 'Average connections per IP (connections / unique IPs) and the busiest single IP. An average near 1 is a healthy spread across users; a rising average, or a max approaching the per-IP cap of 128, points to one source clustering many connections, the abuse pattern the per-IP limiter guards against.' + queries: + avg: + # clamp the denominator to 1: websocket_unique_ips is 0 at idle and only + # refreshes once a minute, so an unclamped divide yields NaN (0/0) at idle + # or +Inf (n/0) in the first minute after traffic resumes + promql: sum({{name:websocket_clients}}{job="blockbook", coin="$coin"}) by (coin) / clamp_min(sum({{name:websocket_unique_ips}}{job="blockbook", coin="$coin"}) by (coin), 1) + legend: '{{coin}} - avg per IP' + max: + promql: max({{name:websocket_max_connections_per_ip}}{job="blockbook", coin="$coin"}) by (coin) + legend: '{{coin}} - max per IP' + websocket.balance_history_txs: + title: WebSocket balance-history transactions per request (quantiles) + description: 'Distribution of how many transactions a single WebSocket getBalanceHistory call aggregates — the per-request DB work the cap bounds (one read per transaction). Trezor Suite is WebSocket-only and requests an account''s full history (no from/to) for its balance graph, so this is the signal for sizing the WS cap (default 1000000): if p99 sits far below the cap, the cap has ample headroom and never breaks a Suite graph; if it climbs toward the cap, heavy real accounts are getting close. Split by path (address vs xpub; xpub sums across derived addresses). Values are clamped at cap+1 when a request is rejected, so a flat line at the cap means requests are hitting it — read alongside the cap-rejections panel.' + queries: + p50: + promql: histogram_quantile(0.50, sum(rate({{name:balance_history_txs}}_bucket{job="blockbook", coin="$coin", transport="ws"}[$__rate_interval])) by (coin, path, le)) + legend: '{{coin}} - {{path}} p50' + p95: + promql: histogram_quantile(0.95, sum(rate({{name:balance_history_txs}}_bucket{job="blockbook", coin="$coin", transport="ws"}[$__rate_interval])) by (coin, path, le)) + legend: '{{coin}} - {{path}} p95' + p99: + promql: histogram_quantile(0.99, sum(rate({{name:balance_history_txs}}_bucket{job="blockbook", coin="$coin", transport="ws"}[$__rate_interval])) by (coin, path, le)) + legend: '{{coin}} - {{path}} p99' + websocket.balance_history_cap_exceeded: + title: WebSocket balance-history cap rejections per minute + description: 'Rate of WebSocket getBalanceHistory requests rejected because the requested range spanned more transactions than the WS cap (default 1000000). For paying Suite customers this should stay at zero: a non-zero value means a real WS client (most likely a heavy Suite account asking for its full-history graph) is being turned away and that account''s graph is broken — the cue to raise _WS_BALANCE_HISTORY_MAX_TXS. Split by path (address vs xpub).' + queries: + by_path: + promql: sum(rate({{name:balance_history_cap_exceeded_total}}{job="blockbook", coin="$coin", transport="ws"}[$__rate_interval])) by (coin, path) * 60 + legend: '{{coin}} - {{path}}/min' + row.rest_api: + title: REST API + rest_api.requests: + title: REST API requests per minute (by endpoint) + description: 'Rate of public REST API (/api/v2/...) calls, broken down by endpoint. Trezor Suite talks to Blockbook over WebSocket only, so REST traffic is non-Suite: explorer UI, third-party integrations, scrapers or bots. A sustained high rate, or one endpoint dominating, points to an automated client rather than wallet users. Counts only the JSON API actions, not WebSocket or HTML explorer views.' + queries: + by_action: + promql: sum(rate({{name:explorer_views}}{job="blockbook", coin="$coin", action=~"api-.*"}[$__rate_interval])) by (coin, action) * 60 + legend: '{{coin}} - {{action}}/min' + rest_api.balance_history_requests: + title: REST API balance-history requests per minute + description: 'Rate of the balance-history endpoints (/api/v2/balancehistory and the xpub variant), the most expensive REST calls: each aggregates one DB read per transaction, so an unbounded request over a high-volume address or xpub is cheap to send and costly to serve. Requests past the per-request cap (default 250000 txs) are rejected with HTTP 400 asking to narrow the from/to range. A rising rate here, especially against large addresses, is the automated/DoS pattern that cap guards against.' + queries: + by_action: + promql: sum(rate({{name:explorer_views}}{job="blockbook", coin="$coin", action=~"api-.*balancehistory"}[$__rate_interval])) by (coin, action) * 60 + legend: '{{coin}} - {{action}}/min' + rest_ui.rejections: + title: Public HTTP rate-limit rejections per minute + description: 'Public HTTP requests (explorer UI pages and REST API, sharing one per-client budget) the per-IP limiter rejected before any handler work (HTTP 429), by reason: request_rate is over the per-client request budget (default 180/min, burst 40), concurrent_requests is more than the per-client in-flight cap (default 12), ip_blocked is a client on the temporary blocklist (only when block duration is enabled; off by default). Sustained non-zero values mean abuse is being actively throttled.' + queries: + by_reason: + promql: sum(rate({{name:rest_ui_rate_limit_rejections}}{job="blockbook", coin="$coin"}[$__rate_interval])) by (coin, reason) * 60 + legend: '{{coin}} - {{reason}}/min' + rest_ui.active_ips: + title: Public HTTP active vs blocked client IPs + description: 'Distinct client keys (IPv4 address or IPv6 /64 prefix) with at least one in-flight public HTTP request (explorer UI or REST API), plotted against the number currently on the temporary blocklist. Active IPs is the breadth of concurrent callers; a single source pushing heavy traffic shows up not here but as a high busiest-client value. Blocked IPs stays at 0 unless block duration is configured, then it counts clients shut out after repeated rate/concurrency breaches.' + queries: + active: + promql: sum({{name:rest_ui_active_ips}}{job="blockbook", coin="$coin"}) by (coin) + legend: '{{coin}} - active IPs' + blocked: + promql: sum({{name:rest_ui_blocked_ips}}{job="blockbook", coin="$coin"}) by (coin) + legend: '{{coin}} - blocked IPs' + rest_ui.max_active_per_ip: + title: Public HTTP busiest client (in-flight requests) + description: 'Largest number of in-flight public HTTP requests (explorer UI or REST API) held by any single client key. A value climbing toward the per-client concurrency cap (default 12) while active IPs stays low means one source is holding many expensive handlers open at once, the per-client concurrency abuse the limiter guards against.' + queries: + max: + promql: max({{name:rest_ui_max_active_requests_per_ip}}{job="blockbook", coin="$coin"}) by (coin) + legend: '{{coin}} - max in-flight per IP' + rest_api.balance_history_txs: + title: REST API balance-history transactions per request (quantiles) + description: 'Distribution of how many transactions a single REST /api/v2/balancehistory call aggregates — the per-request DB work the cap bounds (one read per transaction). REST is the open, unauthenticated surface (non-Suite: explorer, integrations, bots), so this is where a cheap-to-send, expensive-to-serve request shows up; use it to judge whether the REST cap (default 250000) is sized right. Split by path (address vs xpub; xpub sums across derived addresses). Values are clamped at cap+1 when a request is rejected, so a flat line at the cap means requests are hitting it — read alongside the cap-rejections panel.' + queries: + p50: + promql: histogram_quantile(0.50, sum(rate({{name:balance_history_txs}}_bucket{job="blockbook", coin="$coin", transport="rest"}[$__rate_interval])) by (coin, path, le)) + legend: '{{coin}} - {{path}} p50' + p95: + promql: histogram_quantile(0.95, sum(rate({{name:balance_history_txs}}_bucket{job="blockbook", coin="$coin", transport="rest"}[$__rate_interval])) by (coin, path, le)) + legend: '{{coin}} - {{path}} p95' + p99: + promql: histogram_quantile(0.99, sum(rate({{name:balance_history_txs}}_bucket{job="blockbook", coin="$coin", transport="rest"}[$__rate_interval])) by (coin, path, le)) + legend: '{{coin}} - {{path}} p99' + rest_api.balance_history_cap_exceeded: + title: REST API balance-history cap rejections per minute + description: 'Rate of REST balance-history requests rejected (HTTP 400) because the requested range spanned more transactions than the REST cap (default 250000). Unlike the WS side, non-zero here is expected and healthy — it is the open REST surface shedding the unbounded, high-volume-address scans the cap exists to block. A sustained climb, especially concentrated on the xpub path or one source, is the automated/DoS pattern; pair it with the REST rate-limit rejections and busiest-client panels. Split by path (address vs xpub).' + queries: + by_path: + promql: sum(rate({{name:balance_history_cap_exceeded_total}}{job="blockbook", coin="$coin", transport="rest"}[$__rate_interval])) by (coin, path) * 60 + legend: '{{coin}} - {{path}}/min' + row.general: + title: General + general.synchronized: + title: Synchronized (all coins) + description: 1 = Blockbook reports itself in sync with the backend, 0 = not in sync (mirrors /api/status inSync). Shown across all coins. + queries: + synchronized: + promql: '{{name:synchronized}}{job="blockbook"}' + legend: '{{coin}}' + general.block_height: + title: Block height (Blockbook vs backend) + description: A persistent gap means indexing is behind the backend tip; compare with synchronized and tip-age panels before paging. + queries: + blockbook: + promql: '{{name:best_height}}{job="blockbook", coin="$coin"}' + legend: Blockbook {{coin}} - {{instance}} + backend: + promql: '{{name:backend_best_height}}{job="blockbook", coin="$coin"}' + legend: Backend {{coin}} - {{instance}} + general.tip_age: + title: Backend tip age + description: Grows when the backend tip stops advancing, even if Blockbook is still serving requests. + queries: + age: + promql: '{{name:tip_age_seconds}}{job="blockbook", coin="$coin"}' + legend: '{{coin}} - {{instance}}' + general.avg_block_period: + title: Moving average of the last 100 blocks mining period (seconds) + description: Observed chain pace; compare with configured average block time for stalled-tip heuristics. + queries: + period: + promql: '{{name:avg_block_period}}{job="blockbook", coin="$coin"}' + legend: '{{coin}} - {{instance}}' + general.avg_block_time: + title: Configured average block time + description: Static chain config value used by sync heuristics; 0 means unavailable. + queries: + block_time: + promql: '{{name:average_block_time_seconds}}{job="blockbook"}' + legend: __auto + general.versions: + title: Blockbook & backend versions + description: Values live in metric labels, so the table is mainly for comparing deployed build/backend versions across instances. + queries: + info: + promql: '{{name:app_info}}{job="blockbook", coin="$coin"}' + legend: __auto + general.index_db_size: + title: Index DB size + description: Whole RocksDB footprint; pair with DB column size to identify the column family driving growth. + queries: + size: + promql: '{{name:index_db_size}}{job="blockbook", coin="$coin"}' + legend: '{{coin}} - {{instance}}' + general.dbcolumn_size: + title: RocksDB column-family size + description: Helps identify whether transactions, addresses or auxiliary indexes dominate disk usage. + queries: + size: + promql: '{{name:dbcolumn_size}}{job="blockbook", coin="$coin"}' + legend: '{{column}}' + general.cached_transactions: + title: Number of cached transactions + description: Tracks the transaction column family row count, not a separate in-memory cache. + queries: + rows: + promql: '{{name:dbcolumn_rows}}{job="blockbook", column="transactions", coin="$coin"}' + legend: '{{coin}} - {{instance}}' + general.txcache_hit_ratio: + title: txCache hit ratio % + description: Derived from hit and miss counters; low values mean transaction reads are bypassing the cache or churning it. + queries: + ratio: + promql: 100 * sum({{name:txcache_efficiency}}{job="blockbook", status="hit", coin="$coin"}) by (coin, instance) / sum({{name:txcache_efficiency}}{job="blockbook", coin="$coin"}) by (coin, instance) + legend: '{{coin}} - {{instance}}' + general.xpub_cache_size: + title: Xpub cache size + description: Populated only for UTXO coins; growth reflects active xpub scanning and lookup reuse. + queries: + size: + promql: '{{name:xpub_cache_size}}{job="blockbook", coin="$coin"}' + legend: '{{coin}} - {{instance}}' + general.explorer_views: + title: Explorer views in 1 minute + description: Action labels distinguish UI routes, public API endpoints and redirects sharing the same HTTP server. + queries: + views: + promql: sum(rate({{name:explorer_views}}{job="blockbook", coin="$coin"}[1m])) by (action) * 60 + legend: '{{action}}' + general.explorer_pending_requests: + title: Explorer pending requests + description: In-flight HTTP handlers; high values can indicate slow handlers even when request rate is normal. + queries: + pending: + promql: sum({{name:explorer_pending_requests}}{job="blockbook", coin="$coin"}) by (coin, method) + legend: Explorer {{method}} - {{coin}} + general.pending_requests: + title: Pending requests - by method + description: Compares queued work across websocket and HTTP handlers to show which interface is tying up workers. + queries: + websocket: + promql: sum({{name:websocket_pending_requests}}{job="blockbook", coin="$coin"}) by (coin, method) + legend: Websocket {{method}} - {{coin}} + explorer: + promql: sum({{name:explorer_pending_requests}}{job="blockbook", coin="$coin"}) by (coin, method) + legend: Explorer {{method}} - {{coin}} + general.estimated_fee: + title: Estimated fee rate + description: Fee estimates for 1/3/5/8 block targets; units are chain-specific. + queries: + blocks_1: + promql: ({{name:estimated_fee}}{job="blockbook", coin="$coin", blocks="1"}/1000) + legend: '{{coin}} - {{instance}} - {{blocks}} blocks' + blocks_3: + promql: ({{name:estimated_fee}}{job="blockbook", coin="$coin", blocks="3"}/1000) + legend: '{{coin}} - {{instance}} - {{blocks}} blocks' + blocks_5: + promql: ({{name:estimated_fee}}{job="blockbook", coin="$coin", blocks="5"}/1000) + legend: '{{coin}} - {{instance}} - {{blocks}} blocks' + blocks_8: + promql: ({{name:estimated_fee}}{job="blockbook", coin="$coin", blocks="8"}/1000) + legend: '{{coin}} - {{instance}} - {{blocks}} blocks' + general.coingecko_requests: + title: CoinGecko requests + description: Status separates successful calls, throttling and hard errors from the same endpoint. + queries: + per_server: + promql: '{{name:coingecko_requests}}{job="blockbook", coin="$coin"}' + legend: '{{coin}} - {{instance}} - {{status}} - {{endpoint}}' + total: + promql: sum({{name:coingecko_requests}}{job="blockbook", coin="$coin"}) + legend: '{{coin}} total across servers' + general.coingecko_range_requests: + title: CoinGecko historical range choices + description: Reveals whether downloads are full, capped, tip-only or skipped, which explains missing or stale fiat history. + queries: + per_server: + promql: '{{name:coingecko_range_requests}}{job="blockbook", coin="$coin"}' + legend: '{{coin}} - {{instance}} - {{range}}' + total: + promql: sum({{name:coingecko_range_requests}}{job="blockbook", coin="$coin"}) + legend: '{{coin}} total across servers' + general.alt_fee_provider_requests: + title: Alternative fee provider requests + description: External fee-source health by provider; status distinguishes HTTP, network and decode failures. + queries: + requests: + promql: rate({{name:alternative_fee_provider_requests}}{job="blockbook", coin="$coin"}[$__rate_interval]) + legend: '{{instance}} - {{provider}} - {{status}}' + general.balance_history_fiat_duration_p95: + title: 95% quantile of balance history fiat stage duration + description: Separates batch lookup from per-point fallback, so slow fallback behavior is visible in address and xpub paths. + queries: + p95: + promql: histogram_quantile(0.95, sum(rate({{name:balance_history_fiat_duration_seconds}}_bucket{job="blockbook", coin="$coin"}[1m])) by (coin, path, mode, status, le)) + legend: '{{coin}} - {{path}} - {{mode}} - {{status}}' + general.balance_history_fiat_fallbacks: + title: Balance history fiat fallbacks per minute + description: Counts failed batch fiat lookups that had to fall back to per-point ticker queries. + queries: + fallbacks: + promql: sum(rate({{name:balance_history_fiat_fallback_total}}{job="blockbook", coin="$coin"}[1m])) by (coin, path, reason) * 60 + legend: '{{coin}} - {{path}} - {{reason}}' + general.balance_history_points_p95: + title: 95% quantile of balance history output points + description: Response size proxy for balance-history endpoints; larger point counts make fiat enrichment more expensive. + queries: + p95: + promql: histogram_quantile(0.95, sum(rate({{name:balance_history_points}}_bucket{job="blockbook", coin="$coin"}[1m])) by (coin, path, le)) + legend: '{{coin}} - {{path}}' + general.fiat_update_duration_p95: + title: 95% quantile of fiat update stage duration + description: Stage labels distinguish current, hourly, five-minute, historical and token ticker download work. + queries: + p95: + promql: histogram_quantile(0.95, sum(increase({{name:fiat_rates_update_duration_seconds}}_bucket{job="blockbook", coin="$coin"}[1h])) by (coin, stage, status, le)) + legend: '{{coin}} - {{stage}} - {{status}}' + row.fiat_rates: + title: Fiat Rates + fiatrates.tip_fetches: + title: Chain tip fetches (<=1 day, Free tier) + description: Daily fiat-rate points fetched for the chain tip (gap of one day), the only historical range still served from the rate-limited CoinGecko Free tier. Steady-state activity here is normal; sustained zero across a day means the tip update is failing. + queries: + units: + promql: sum(rate({{name:fiat_rates_fetched_units_total}}{job="blockbook", coin="$coin", phase="tip"}[$__rate_interval])) by (coin, instance) * 60 + legend: '{{coin}} - {{instance}} tip points/min' + fiatrates.backfill_fetches: + title: Backfill fetches (>1 day, via CDN) + description: Higher-volume historical ranges routed to the Trezor CDN — multi-day downtime catch-up (backfill) and full-history population (bootstrap). units are daily points; tokens are token series. These should stay off the Free tier. + queries: + units: + promql: sum(rate({{name:fiat_rates_fetched_units_total}}{job="blockbook", coin="$coin", phase=~"backfill|bootstrap"}[$__rate_interval])) by (phase) * 60 + legend: '{{coin}} - {{phase}} points/min' + tokens: + promql: sum(rate({{name:fiat_rates_fetched_tokens_total}}{job="blockbook", coin="$coin", phase=~"backfill|bootstrap"}[$__rate_interval])) by (phase) * 60 + legend: '{{coin}} - {{phase}} tokens/min' + fiatrates.reconcile: + title: Startup reconciliation (self-healing) + description: Daily points and token series repaired by the blocking at-startup self-healing pass via the CDN. Bursts at process start are expected; recurring large bursts on every restart suggest persistent holes the provider cannot fill. + queries: + units: + promql: sum(increase({{name:fiat_rates_fetched_units_total}}{job="blockbook", coin="$coin", phase="reconcile"}[1h])) by (coin, instance) + legend: '{{coin}} - {{instance}} points repaired/h' + tokens: + promql: sum(increase({{name:fiat_rates_fetched_tokens_total}}{job="blockbook", coin="$coin", phase="reconcile"}[1h])) by (coin, instance) + legend: '{{coin}} - {{instance}} tokens repaired/h' + fiatrates.unable: + title: Unable to fetch (by reason) + description: Fiat-rate series that could not be fetched, split by phase and reason. gap_too_large flags series stale beyond the reconcile guard (probable bug); fetch_failed/provider_banned are transport failures; no_base_ticker is a token day lacking a base-currency ticker; budget_exhausted means the startup reconcile wall-clock budget elapsed before completion (remaining series retried next startup). + queries: + by_reason: + promql: sum(increase({{name:fiat_rates_unable_total}}{job="blockbook", coin="$coin"}[1h])) by (phase, reason) + legend: '{{coin}} - {{phase}} - {{reason}}' diff --git a/configs/grafana/template.json b/configs/grafana/template.json new file mode 100644 index 0000000000..bfc9fe1a89 --- /dev/null +++ b/configs/grafana/template.json @@ -0,0 +1,5707 @@ +{ + "__elements": {}, + "__inputs": [ + { + "description": "", + "label": "Prometheus", + "name": "DS_PROMETHEUS", + "pluginId": "prometheus", + "pluginName": "Prometheus", + "type": "datasource" + } + ], + "__requires": [ + { + "id": "grafana", + "name": "Grafana", + "type": "grafana", + "version": "12.3.0" + }, + { + "id": "prometheus", + "name": "Prometheus", + "type": "datasource", + "version": "1.0.0" + }, + { + "id": "stat", + "name": "Stat", + "type": "panel", + "version": "" + }, + { + "id": "table", + "name": "Table", + "type": "panel", + "version": "" + }, + { + "id": "timeseries", + "name": "Time series", + "type": "panel", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "id": 312, + "panels": [], + "type": "row", + "x-panel-key": "row.system" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "id": 34, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "resident" + } + ], + "type": "timeseries", + "x-panel-key": "system.resident_memory" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "id": 24, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "virtual" + } + ], + "type": "timeseries", + "x-panel-key": "system.virtual_memory" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "id": 43, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "allocated" + } + ], + "type": "timeseries", + "x-panel-key": "system.go_alloc" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 22, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "fds" + } + ], + "type": "timeseries", + "x-panel-key": "system.open_fds" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 32, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "load" + } + ], + "type": "timeseries", + "x-panel-key": "system.node_load1" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "id": 36, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "available" + } + ], + "type": "timeseries", + "x-panel-key": "system.node_mem_available" + }, + { + "collapsed": false, + "id": 313, + "panels": [], + "type": "row", + "x-panel-key": "row.rpc" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "req / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 18, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "requests" + } + ], + "type": "timeseries", + "x-panel-key": "rpc.request_rate" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "id": 20, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "p95" + } + ], + "type": "timeseries", + "x-panel-key": "rpc.request_duration_p95" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 302, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "fallbacks" + } + ], + "type": "timeseries", + "x-panel-key": "rpc.fallback_calls" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "calls / sec", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 105, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "requests" + } + ], + "type": "timeseries", + "x-panel-key": "rpc.eth_call_requests" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "errors / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 106, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "errors" + } + ], + "type": "timeseries", + "x-panel-key": "rpc.eth_call_errors" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "contracts / batch", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 107, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "avg" + } + ], + "type": "timeseries", + "x-panel-key": "rpc.eth_call_batch_size" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "calls / sec", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 108, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "requests" + } + ], + "type": "timeseries", + "x-panel-key": "rpc.eth_call_token_uri" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "calls / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 109, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "requests" + } + ], + "type": "timeseries", + "x-panel-key": "rpc.eth_call_staking_pool" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "calls / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 110, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "requests" + } + ], + "type": "timeseries", + "x-panel-key": "rpc.eth_call_contract_info" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 111, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "errors" + } + ], + "type": "timeseries", + "x-panel-key": "rpc.eth_sync_errors" + }, + { + "collapsed": false, + "id": 314, + "panels": [], + "type": "row", + "x-panel-key": "row.sync" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "id": 14, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "p95" + } + ], + "type": "timeseries", + "x-panel-key": "sync.index_resync_duration_p95" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 303, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "errors" + } + ], + "type": "timeseries", + "x-panel-key": "sync.index_resync_errors" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "auto", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 58, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "retries" + } + ], + "type": "timeseries", + "x-panel-key": "sync.index_block_not_found_retries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "auto", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 59, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "events" + } + ], + "type": "timeseries", + "x-panel-key": "sync.index_reorg_events" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "auto", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 60, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "yields" + } + ], + "type": "timeseries", + "x-panel-key": "sync.index_sync_yields" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 70, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "stats" + } + ], + "type": "timeseries", + "x-panel-key": "sync.block_stats_tip" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 71, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "stats" + } + ], + "type": "timeseries", + "x-panel-key": "sync.block_stats_bulk" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 28, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "size" + } + ], + "type": "timeseries", + "x-panel-key": "sync.mempool_size" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 332, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "actions" + } + ], + "type": "timeseries", + "x-panel-key": "sync.eth_alternative_mempool_reconciliation" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 338, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "size" + } + ], + "type": "timeseries", + "x-panel-key": "sync.eth_alternative_mempool_cache_size" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "id": 339, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "p50" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "p95" + } + ], + "type": "timeseries", + "x-panel-key": "sync.eth_alternative_mempool_tx_residence" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 54, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "p95" + } + ], + "type": "timeseries", + "x-panel-key": "sync.mempool_resync_throughput_p95" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "id": 304, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "p95" + } + ], + "type": "timeseries", + "x-panel-key": "sync.mempool_resync_duration_p95" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 100, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "stats" + } + ], + "type": "timeseries", + "x-panel-key": "sync.hotness_stats_tip" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 101, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "stats" + } + ], + "type": "timeseries", + "x-panel-key": "sync.hotness_stats_bulk" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 102, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "entries" + } + ], + "type": "timeseries", + "x-panel-key": "sync.addr_contracts_cache_entries" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "id": 103, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "bytes" + } + ], + "type": "timeseries", + "x-panel-key": "sync.addr_contracts_cache_bytes" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "events / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 104, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "hits" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "misses" + }, + { + "editorMode": "code", + "range": true, + "refId": "C", + "x-query-key": "flushes" + } + ], + "type": "timeseries", + "x-panel-key": "sync.addr_contracts_cache_activity" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*stall threshold.*" + }, + "properties": [ + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "id": 308, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "age" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "threshold" + } + ], + "type": "timeseries", + "x-panel-key": "sync.tip_feed_age" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 309, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "stall" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "tip_advanced" + } + ], + "type": "timeseries", + "x-panel-key": "sync.tip_feed_watchdog" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 310, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "failures" + } + ], + "type": "timeseries", + "x-panel-key": "sync.tip_feed_reconnect_failures" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 311, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "events" + } + ], + "type": "timeseries", + "x-panel-key": "sync.tip_feed_events" + }, + { + "collapsed": false, + "id": 315, + "panels": [], + "type": "row", + "x-panel-key": "row.websocket" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 37, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "clients" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.clients" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "req / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 38, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "requests" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.request_rate" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 42, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "subscribes" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "new_block_txs" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.subscriptions" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "notif / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 331, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "notifications" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.new_block_notifications" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "µs" + }, + "overrides": [] + }, + "id": 46, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "p95" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.request_duration_p95" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "events / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 47, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "events" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.new_block_txs_rate" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "id": 48, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "p95" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.new_block_txs_duration_p95" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "notif / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 49, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "notifications" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.addr_notifications" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "events / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 50, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "closes" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "unknown" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.channel_closes_unknown" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "events / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 112, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "outcomes" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.eth_receipt" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 317, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "p50" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "p99" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.connection_requests" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "events / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 318, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "rejections" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.connection_rejections" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 319, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "connections" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "unique_ips" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.unique_ips" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 320, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "avg" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "max" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.connections_per_ip" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "transactions", + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 327, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "p50" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "p95" + }, + { + "editorMode": "code", + "range": true, + "refId": "C", + "x-query-key": "p99" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.balance_history_txs" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "rejections / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 328, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "by_path" + } + ], + "type": "timeseries", + "x-panel-key": "websocket.balance_history_cap_exceeded" + }, + { + "collapsed": false, + "id": 321, + "panels": [], + "type": "row", + "x-panel-key": "row.rest_api" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "requests / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 322, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "by_action" + } + ], + "type": "timeseries", + "x-panel-key": "rest_api.requests" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "requests / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 323, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "by_action" + } + ], + "type": "timeseries", + "x-panel-key": "rest_api.balance_history_requests" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "rejections / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 324, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "by_reason" + } + ], + "type": "timeseries", + "x-panel-key": "rest_ui.rejections" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 325, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "active" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "blocked" + } + ], + "type": "timeseries", + "x-panel-key": "rest_ui.active_ips" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 326, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "max" + } + ], + "type": "timeseries", + "x-panel-key": "rest_ui.max_active_per_ip" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "transactions", + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 329, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "p50" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "p95" + }, + { + "editorMode": "code", + "range": true, + "refId": "C", + "x-query-key": "p99" + } + ], + "type": "timeseries", + "x-panel-key": "rest_api.balance_history_txs" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "rejections / min", + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 330, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "by_path" + } + ], + "type": "timeseries", + "x-panel-key": "rest_api.balance_history_cap_exceeded" + }, + { + "collapsed": false, + "id": 316, + "panels": [], + "type": "row", + "x-panel-key": "row.general" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [ + { + "options": { + "0": { + "text": "OUT OF SYNC" + }, + "1": { + "text": "in sync" + } + }, + "type": "value" + } + ], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": 0 + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 300, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "instant": true, + "range": false, + "refId": "A", + "x-query-key": "synchronized" + } + ], + "type": "stat", + "x-panel-key": "general.synchronized" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 301, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "blockbook" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "backend" + } + ], + "type": "timeseries", + "x-panel-key": "general.block_height" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "id": 63, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "age" + } + ], + "type": "timeseries", + "x-panel-key": "general.tip_age" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "id": 44, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "period" + } + ], + "type": "timeseries", + "x-panel-key": "general.avg_block_period" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "id": 64, + "options": { + "cellHeight": "sm", + "showHeader": true + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "builder", + "exemplar": false, + "format": "table", + "instant": true, + "range": false, + "refId": "A", + "x-query-key": "block_time" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "env": true, + "job": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": {} + } + } + ], + "type": "table", + "x-panel-key": "general.avg_block_time" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 307, + "options": { + "cellHeight": "sm", + "showHeader": true + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "format": "table", + "instant": true, + "range": false, + "refId": "A", + "x-query-key": "info" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "Value": true, + "__name__": true, + "env": true, + "job": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": {} + } + } + ], + "type": "table", + "x-panel-key": "general.versions" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "log": 2, + "type": "log" + }, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "id": 10, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "min" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "size" + } + ], + "type": "timeseries", + "x-panel-key": "general.index_db_size" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "decbytes" + }, + "overrides": [] + }, + "id": 305, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "size" + } + ], + "type": "timeseries", + "x-panel-key": "general.dbcolumn_size" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 30, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "rows" + } + ], + "type": "timeseries", + "x-panel-key": "general.cached_transactions" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "id": 16, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "ratio" + } + ], + "type": "timeseries", + "x-panel-key": "general.txcache_hit_ratio" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 41, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "size" + } + ], + "type": "timeseries", + "x-panel-key": "general.xpub_cache_size" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 26, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "views" + } + ], + "type": "timeseries", + "x-panel-key": "general.explorer_views" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 306, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "pending" + } + ], + "type": "timeseries", + "x-panel-key": "general.explorer_pending_requests" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 40, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "websocket" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "explorer" + } + ], + "type": "timeseries", + "x-panel-key": "general.pending_requests" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 45, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "blocks_1" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "blocks_3" + }, + { + "editorMode": "code", + "range": true, + "refId": "C", + "x-query-key": "blocks_5" + }, + { + "editorMode": "code", + "range": true, + "refId": "D", + "x-query-key": "blocks_8" + } + ], + "type": "timeseries", + "x-panel-key": "general.estimated_fee" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 39, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "per_server" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "total" + } + ], + "type": "timeseries", + "x-panel-key": "general.coingecko_requests" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 56, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "per_server" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "total" + } + ], + "type": "timeseries", + "x-panel-key": "general.coingecko_range_requests" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 57, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "requests" + } + ], + "type": "timeseries", + "x-panel-key": "general.alt_fee_provider_requests" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "id": 51, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "p95" + } + ], + "type": "timeseries", + "x-panel-key": "general.balance_history_fiat_duration_p95" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 52, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "fallbacks" + } + ], + "type": "timeseries", + "x-panel-key": "general.balance_history_fiat_fallbacks" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 53, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "p95" + } + ], + "type": "timeseries", + "x-panel-key": "general.balance_history_points_p95" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "id": 55, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "p95" + } + ], + "type": "timeseries", + "x-panel-key": "general.fiat_update_duration_p95" + }, + { + "collapsed": false, + "id": 333, + "panels": [], + "type": "row", + "x-panel-key": "row.fiat_rates" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 334, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "units" + } + ], + "type": "timeseries", + "x-panel-key": "fiatrates.tip_fetches" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 335, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "units" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "tokens" + } + ], + "type": "timeseries", + "x-panel-key": "fiatrates.backfill_fetches" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 336, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "units" + }, + { + "editorMode": "code", + "range": true, + "refId": "B", + "x-query-key": "tokens" + } + ], + "type": "timeseries", + "x-panel-key": "fiatrates.reconcile" + }, + { + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "id": 337, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "max", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "editorMode": "code", + "range": true, + "refId": "A", + "x-query-key": "by_reason" + } + ], + "type": "timeseries", + "x-panel-key": "fiatrates.unable" + } + ], + "preload": false, + "refresh": "1m", + "schemaVersion": 41, + "tags": [], + "templating": { + "list": [ + { + "allowCustomValue": true, + "current": { + "selected": true, + "text": "Polygon Archive", + "value": "Polygon Archive" + }, + "hide": 0, + "includeAll": false, + "label": "Coin", + "multi": false, + "name": "coin", + "options": [ + { + "selected": false, + "text": "Bitcoin", + "value": "Bitcoin" + }, + { + "selected": false, + "text": "Ethereum Archive", + "value": "Ethereum Archive" + }, + { + "selected": true, + "text": "Polygon Archive", + "value": "Polygon Archive" + }, + { + "selected": false, + "text": "BNB Smart Chain Archive", + "value": "BNB Smart Chain Archive" + }, + { + "selected": false, + "text": "Arbitrum Archive", + "value": "Arbitrum Archive" + }, + { + "selected": false, + "text": "Base Archive", + "value": "Base Archive" + }, + { + "selected": false, + "text": "Optimism Archive", + "value": "Optimism Archive" + }, + { + "selected": false, + "text": "Litecoin", + "value": "Litecoin" + }, + { + "selected": false, + "text": "Namecoin", + "value": "Namecoin" + }, + { + "selected": false, + "text": "Vertcoin", + "value": "Vertcoin" + }, + { + "selected": false, + "text": "Zcash", + "value": "Zcash" + }, + { + "selected": false, + "text": "Bgold", + "value": "Bgold" + }, + { + "selected": false, + "text": "Bcash", + "value": "Bcash" + }, + { + "selected": false, + "text": "Dash", + "value": "Dash" + }, + { + "selected": false, + "text": "DigiByte", + "value": "DigiByte" + }, + { + "selected": false, + "text": "Dogecoin", + "value": "Dogecoin" + }, + { + "selected": false, + "text": "Ethereum Classic", + "value": "Ethereum Classic" + }, + { + "selected": false, + "text": "Testnet", + "value": "Testnet" + }, + { + "selected": false, + "text": "Ethereum Testnet Holesky Archive", + "value": "Ethereum Testnet Holesky Archive" + }, + { + "selected": false, + "text": "Ethereum Testnet Sepolia Archive", + "value": "Ethereum Testnet Sepolia Archive" + }, + { + "selected": false, + "text": "Tron", + "value": "Tron" + }, + { + "selected": false, + "text": "Avalanche Archive", + "value": "Avalanche Archive" + } + ], + "query": "Bitcoin,Ethereum Archive,Polygon Archive,BNB Smart Chain Archive,Arbitrum Archive,Base Archive,Optimism Archive,Litecoin,Namecoin,Vertcoin,Zcash,Bgold,Bcash,Dash,DigiByte,Dogecoin,Ethereum Classic,Testnet,Ethereum Testnet Holesky Archive,Ethereum Testnet Sepolia Archive,Tron,Avalanche Archive", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-12h", + "to": "now" + }, + "timepicker": { + "hidden": false, + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Blockbook v2", + "uid": "blockbook-v2", + "version": 1, + "weekStart": "" +} diff --git a/configs/metrics.yaml b/configs/metrics.yaml new file mode 100644 index 0000000000..2293480acd --- /dev/null +++ b/configs/metrics.yaml @@ -0,0 +1,380 @@ +prefix: blockbook_ +metrics: + websocket_requests: + name: blockbook_websocket_requests + type: counter_vec + help: Method calls accepted by the websocket API, labeled by method and success/failure status + labels: [method, status] + websocket_subscribes: + name: blockbook_websocket_subscribes + type: gauge_vec + help: Active websocket subscription gauges, one series per subscription method + labels: [method] + websocket_clients: + name: blockbook_websocket_clients + type: gauge + help: Open client connections to the websocket API + websocket_req_duration: + name: blockbook_websocket_req_duration + type: histogram_vec + help: Time spent handling each websocket request, in microseconds, labeled by method + labels: [method] + buckets: [10, 100, 1000, 10000, 100000, 1000000, 100000000] + websocket_channel_closes: + name: blockbook_websocket_channel_closes + type: counter_vec + help: Connection close events observed by the websocket handler, split by close reason + labels: [reason] + websocket_unknown_methods: + name: blockbook_websocket_unknown_methods + type: counter_vec + help: Websocket requests rejected because the method name is not supported + labels: [method] + websocket_addr_notifications: + name: blockbook_websocket_addr_notifications + type: counter_vec + help: Per-address transaction notifications sent over websockets, split by mempool or new-block source + labels: [source] + websocket_new_block_txs: + name: blockbook_websocket_new_block_txs + type: counter_vec + help: Outcomes while matching, converting and publishing confirmed block transactions for newBlockTxs subscribers + labels: [stage, status] + websocket_new_block_txs_duration_seconds: + name: blockbook_websocket_new_block_txs_duration_seconds + type: histogram_vec + help: Time spent in newBlockTxs processing stages such as address matching and transaction conversion + labels: [stage] + buckets: [0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10] + balance_history_fiat_duration_seconds: + name: blockbook_balance_history_fiat_duration_seconds + type: histogram_vec + help: Time spent attaching fiat rates to address or xpub history responses, split by endpoint path, batch/fallback mode and status + labels: [path, mode, status] + buckets: [0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 20] + balance_history_fiat_fallback_total: + name: blockbook_balance_history_fiat_fallback_total + type: counter_vec + help: Batch fiat-rate lookup failures that fell back to per-point lookups, split by endpoint path and reason + labels: [path, reason] + balance_history_points: + name: blockbook_balance_history_points + type: histogram_vec + help: Output point count for address or xpub balance-history responses + labels: [path] + buckets: [1, 2, 5, 10, 20, 40, 80, 160, 320, 640, 1280] + balance_history_txs: + name: blockbook_balance_history_txs + type: histogram_vec + help: Transactions aggregated per balance-history request (the per-request DB work the cap bounds), split by transport and endpoint path; clamped at the configured cap+1 when the cap rejects the request + labels: [transport, path] + buckets: [1, 10, 100, 1000, 5000, 25000, 50000, 100000, 250000, 500000, 1000000, 2000000] + balance_history_cap_exceeded_total: + name: blockbook_balance_history_cap_exceeded_total + type: counter_vec + help: Balance-history requests rejected because the requested range spanned more transactions than the configured cap, split by transport and endpoint path + labels: [transport, path] + websocket_eth_receipt: + name: blockbook_websocket_eth_receipt + type: counter_vec + help: Outcomes when enriching Ethereum newBlockTxs websocket notifications with transaction receipts + labels: [status] + websocket_new_block_txs_subscriptions: + name: blockbook_websocket_new_block_txs_subscriptions + type: gauge + help: Address subscriptions that opted into confirmed-block newBlockTxs notifications + websocket_new_block_notifications: + name: blockbook_websocket_new_block_notifications + type: counter + help: New block notifications delivered to subscribeNewBlock subscribers, incremented by the number of subscribed clients on every indexed block + websocket_connection_requests: + name: blockbook_websocket_connection_requests + type: histogram + help: Requests handled per websocket connection over its lifetime, recorded when the connection closes + buckets: [1, 10, 100, 1000, 10000, 100000, 1000000] + websocket_connection_rejections: + name: blockbook_websocket_connection_rejections + type: counter_vec + help: Websocket connection attempts rejected by the per-IP limiter before upgrade, split by reason + labels: [reason] + websocket_unique_ips: + name: blockbook_websocket_unique_ips + type: gauge + help: Distinct client IP addresses currently holding at least one websocket connection; websocket_clients divided by this is the average connections per IP + websocket_max_connections_per_ip: + name: blockbook_websocket_max_connections_per_ip + type: gauge + help: Largest number of concurrent websocket connections held by any single client IP, the size of the biggest connection cluster + websocket_blocked_ips: + name: blockbook_websocket_blocked_ips + type: gauge + help: Distinct client keys (IPv4 address or IPv6 /64 prefix) currently blocked from opening websocket connections after tripping the per-connection message rate limit + websocket_blocked_connections: + name: blockbook_websocket_blocked_connections + type: counter + help: Websocket connection attempts rejected before upgrade because the client key was on the temporary IP blocklist + rest_ui_rate_limit_rejections: + name: blockbook_rest_ui_rate_limit_rejections + type: counter_vec + help: Public HTTP requests (explorer UI and REST API) rejected by the per-IP limiter before handler work + labels: [reason] + rest_ui_active_ips: + name: blockbook_rest_ui_active_ips + type: gauge + help: Distinct client keys with at least one in-flight public HTTP request (explorer UI or REST API) + rest_ui_max_active_requests_per_ip: + name: blockbook_rest_ui_max_active_requests_per_ip + type: gauge + help: Largest number of in-flight public HTTP requests (explorer UI or REST API) held by one client key + rest_ui_blocked_ips: + name: blockbook_rest_ui_blocked_ips + type: gauge + help: Distinct client keys currently blocked from public HTTP requests (explorer UI or REST API) + index_resync_duration: + name: blockbook_index_resync_duration + type: histogram + help: Time for one successful index resync (a sync-loop iteration that connected blocks), including catch-up work, retry handling and reorg checks, in milliseconds + buckets: [10, 100, 500, 1000, 2000, 5000, 10000] + mempool_resync_duration: + name: blockbook_mempool_resync_duration + type: histogram + help: Time to reload the backend mempool and rebuild Blockbook's in-memory mempool snapshot, in milliseconds + buckets: [10, 100, 500, 1000, 2000, 5000, 10000] + mempool_resync_throughput_txs_per_second: + name: blockbook_mempool_resync_throughput_txs_per_second + type: histogram_vec + help: Transactions returned by a mempool reload divided by reload duration; failures are recorded with status=failure and throughput 0 + labels: [chain, status] + buckets: [0.1, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000] + txcache_efficiency: + name: blockbook_txcache_efficiency + type: counter_vec + help: txCache lookups by outcome (hit or miss) + labels: [status] + rpc_latency: + name: blockbook_rpc_latency + type: histogram_vec + help: Blockbook-to-backend round-trip time in milliseconds, labeled by method and whether the call failed + labels: [method, error] + buckets: [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2000, 5000] + rpc_fallback_calls_total: + name: blockbook_rpc_fallback_calls_total + type: counter_vec + help: Non-primary chain-data paths selected when preferred lookups cannot answer, labeled by component and reason + labels: [component, reason] + eth_call_requests: + name: blockbook_eth_call_requests + type: counter_vec + help: EVM read calls issued by contract metadata, NFT, staking and related enrichment paths, split by call mode + labels: [mode] + eth_call_errors: + name: blockbook_eth_call_errors + type: counter_vec + help: Failed EVM read calls, split by call mode and provider/error class + labels: [mode, type] + eth_call_batch_size: + name: blockbook_eth_call_batch_size + type: histogram + help: EVM read operations coalesced into one backend request + buckets: [1, 2, 5, 10, 20, 50, 100, 200] + eth_call_contract_info_requests: + name: blockbook_eth_call_contract_info_requests + type: counter_vec + help: Contract metadata field lookups such as symbol, name or decimals + labels: [field] + eth_call_token_uri_requests: + name: blockbook_eth_call_token_uri_requests + type: counter_vec + help: NFT metadata lookups for tokenURI-style contract methods + labels: [method] + eth_call_staking_pool_requests: + name: blockbook_eth_call_staking_pool_requests + type: counter_vec + help: Contract field reads used by staking enrichment code + labels: [field] + index_resync_errors: + name: blockbook_index_resync_errors + type: counter_vec + help: Index resync loop failures, labeled by coarse error outcome + labels: [error] + index_block_not_found_retries: + name: blockbook_index_block_not_found_retries + type: counter + help: Number of transient ErrBlockNotFound responses from the backend during sync (load-balanced RPC routing skew, indexer ahead of backend, etc.) + index_reorg_events: + name: blockbook_index_reorg_events + type: counter_vec + help: 'Chain reorganization events detected during sync, by detection path (fork: in-stream prevHash mismatch; resync: missing block hash triggered sync restart; disconnect: local-best fork triggered DB rollback)' + labels: [type] + index_sync_yields: + name: blockbook_index_sync_yields + type: counter_vec + help: 'Number of times sync yielded to the outer resync machinery for non-reorg reasons (reason=deadline: MaxStallDuration elapsed in retry loop; reason=probe_failed: chain-state probe failed for the configured streak)' + labels: [reason] + index_db_size: + name: blockbook_index_db_size + type: gauge + help: Whole RocksDB footprint on disk, in bytes + explorer_views: + name: blockbook_explorer_views + type: counter_vec + help: Explorer UI and public API hits, labeled by route action + labels: [action] + mempool_size: + name: blockbook_mempool_size + type: gauge + help: Transactions retained from the last successful backend reload + eth_alternative_mempool_reconciliation_events_total: + name: blockbook_eth_alternative_mempool_reconciliation_events_total + type: counter_vec + help: Alternative send-transaction mempool cache lifecycle decisions, labeled by action - skipped_fresh and kept retain an entry for the next cycle, provider_error and provider_missing_pending keep an unconfirmed entry pending retry, and mined, nonce_superseded, provider_missing, timeout (from the reconcile loop or a read-path expiry) and rbf_replaced (fee replacement) remove it + labels: [action] + eth_alternative_mempool_tx_residence_seconds: + name: blockbook_eth_alternative_mempool_tx_residence_seconds + type: histogram_vec + help: Age in seconds (time since broadcast) of an alternative send-transaction cache entry at the moment it leaves the cache, labeled by the removing action (mined, nonce_superseded, provider_missing, timeout or rbf_replaced) - reveals how long unconfirmed txs actually survive before each exit reason fires + labels: [action] + buckets: [30, 60, 120, 180, 300, 450, 600, 900, 1200, 1800, 3600] + eth_alternative_mempool_cache_size: + name: blockbook_eth_alternative_mempool_cache_size + type: gauge + help: Transactions currently held in the alternative send-transaction mempool cache, sampled at the end of every reconcile cycle + estimated_fee: + name: blockbook_estimated_fee + type: gauge_vec + help: Fee estimator output by target block count and conservative mode, in chain-specific units + labels: [blocks, conservative] + avg_block_period: + name: blockbook_avg_block_period + type: gauge + help: Observed average spacing of the last 100 blocks' header timestamps, in seconds (chain pace, not indexing cadence) + sync_block_stats: + name: blockbook_sync_block_stats + type: gauge_vec + help: Block processing counters for bulk intervals and chain-tip blocks, labeled by scope and stat kind + labels: [scope, kind] + sync_hotness_stats: + name: blockbook_sync_hotness_stats + type: gauge_vec + help: Ethereum-type hot-address cache activity during bulk and tip processing, labeled by scope and stat kind + labels: [scope, kind] + addr_contracts_cache_entries: + name: blockbook_addr_contracts_cache_entries + type: gauge + help: Number of entries in the Ethereum-type addressContracts in-memory cache + addr_contracts_cache_bytes: + name: blockbook_addr_contracts_cache_bytes + type: gauge + help: Estimated memory used by the Ethereum-type addressContracts cache + addr_contracts_cache_hits_total: + name: blockbook_addr_contracts_cache_hits_total + type: counter + help: addressContracts cache lookups served from memory + addr_contracts_cache_misses_total: + name: blockbook_addr_contracts_cache_misses_total + type: counter + help: addressContracts cache lookups that had to read from RocksDB + addr_contracts_cache_flush_total: + name: blockbook_addr_contracts_cache_flush_total + type: counter_vec + help: addressContracts cache flushes by trigger reason + labels: [reason] + dbcolumn_rows: + name: blockbook_dbcolumn_rows + type: gauge_vec + help: Row count per RocksDB column family + labels: [column] + dbcolumn_size: + name: blockbook_dbcolumn_size + type: gauge_vec + help: Disk footprint per RocksDB column family, in bytes + labels: [column] + app_info: + name: blockbook_app_info + type: gauge_vec + help: Build and backend version information exported as labels with value 1 + labels: [blockbook_version, blockbook_commit, blockbook_buildtime, backend_version, backend_subversion, backend_protocol_version] + best_height: + name: blockbook_best_height + type: gauge + help: Newest block height persisted in Blockbook's index + synchronized: + name: blockbook_synchronized + type: gauge + help: 1 when /api/status reports inSync, 0 otherwise; sustained 0 outside initial sync means the index is not keeping up with the tip, for example after a stalled tip feed + backend_best_height: + name: blockbook_backend_best_height + type: gauge + help: Current backend tip height as last observed by Blockbook + tip_age_seconds: + name: blockbook_tip_age_seconds + type: gauge + help: Seconds since polling last observed the backend best height advance + backend_subscription_age_seconds: + name: blockbook_backend_subscription_age_seconds + type: gauge + help: Seconds since the push tip feed last delivered a notification; high or growing values indicate a stalled feed + backend_subscription_events: + name: blockbook_backend_subscription_events + type: counter_vec + help: Push tip-feed lifecycle and watchdog events, labeled by subscription (newHeads, newPendingTransactions, rpc, or zeromq for Tron) and event (error, resubscribed, resubscribe_failed, watchdog_tick, watchdog_stall, watchdog_reconnect, watchdog_reconnect_failed, watchdog_tip_advanced, watchdog_tip_rollback); watchdog_tick is emitted once per watchdog evaluation + labels: [subscription, event] + average_block_time_seconds: + name: blockbook_average_block_time_seconds + type: gauge + help: Configured nominal block interval for the chain, in seconds; 0 means unavailable + explorer_pending_requests: + name: blockbook_explorer_pending_requests + type: gauge_vec + help: In-flight explorer HTTP handlers, labeled by handler method + labels: [method] + websocket_pending_requests: + name: blockbook_websocket_pending_requests + type: gauge_vec + help: In-flight websocket request handlers, labeled by websocket method + labels: [method] + xpub_cache_size: + name: blockbook_xpub_cache_size + type: gauge + help: UTXO-only population of in-memory extended public-key lookup entries + coingecko_requests: + name: blockbook_coingecko_requests + type: counter_vec + help: External market-data API calls split by endpoint and outcome status + labels: [endpoint, status] + coingecko_range_requests: + name: blockbook_coingecko_range_requests + type: counter_vec + help: Historical ticker download ranges chosen by the fiat updater + labels: [range] + fiat_rates_update_duration_seconds: + name: blockbook_fiat_rates_update_duration_seconds + type: histogram_vec + help: Time spent in fiat-rate downloader stages such as current, hourly, historical and token ticker updates + labels: [stage, status] + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 20, 30, 60, 120, 300] + fiat_rates_fetched_units_total: + name: blockbook_fiat_rates_fetched_units_total + type: counter_vec + help: Daily fiat-rate time-units fetched and persisted, split by fetch phase (tip, backfill, bootstrap, reconcile) + labels: [phase] + fiat_rates_fetched_tokens_total: + name: blockbook_fiat_rates_fetched_tokens_total + type: counter_vec + help: Token fiat-rate series fetched and persisted, split by fetch phase (backfill, bootstrap, reconcile) + labels: [phase] + fiat_rates_unable_total: + name: blockbook_fiat_rates_unable_total + type: counter_vec + help: Fiat-rate series that could not be fetched, split by phase and reason (gap_too_large, fetch_failed, provider_banned, no_base_ticker, budget_exhausted) + labels: [phase, reason] + alternative_fee_provider_requests: + name: blockbook_alternative_fee_provider_requests + type: counter_vec + help: Requests to external fee providers, labeled by provider and outcome status + labels: [provider, status] + eth_sync_rpc_errors: + name: blockbook_eth_sync_rpc_errors + type: counter_vec + help: Ethereum sync RPC failures by method and error status such as timeout, HTTP class, RPC error or generic error + labels: [method, status] diff --git a/configs/metrics_embed.go b/configs/metrics_embed.go new file mode 100644 index 0000000000..1c60cba3c0 --- /dev/null +++ b/configs/metrics_embed.go @@ -0,0 +1,13 @@ +// Package configs embeds configuration assets that ship inside the Blockbook binary. +package configs + +import _ "embed" + +// MetricsYAML is the embedded single source of truth for Blockbook's prometheus +// metrics (configs/metrics.yaml). It is parsed by common.GetMetrics at startup to +// initialize the metric collectors, and read directly by contrib/scripts/render_grafana.py +// to render the Grafana dashboard. Editing a metric's name, help, labels or buckets +// here updates both Blockbook and the dashboard from one place. +// +//go:embed metrics.yaml +var MetricsYAML []byte diff --git a/contrib/gh-vars.sh b/contrib/gh-vars.sh new file mode 100644 index 0000000000..5320c71866 --- /dev/null +++ b/contrib/gh-vars.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Requires bash 4+ (uses `mapfile` and `${var,,}`). +# +# Sourced helper. Fetches Blockbook BB_* repository variables from GitHub via +# the gh CLI and exports them into the current shell, applying the same +# prefix-suffix normalisation used by .github/actions/export-env-vars so that +# locally running tests see the exact same environment as CI. +# +# Trezor-internal: this requires read access to the private trezor/blockbook +# repository's Actions variables. Authenticate with `gh auth login` or export +# GH_TOKEN with `repo` (or `actions:read`) scope, and make sure your GitHub +# account/token is authorised for the Trezor organisation SAML/SSO requirement +# and has access to the repo. Override the source repo with BB_GH_REPO if you +# fork under a different org. +# +# Cache: exports are persisted to ${XDG_CACHE_HOME:-$HOME/.cache}/blockbook/ +# with a 60-minute TTL (chmod 600 — values include QuickNode endpoint paths). +# Force a fresh fetch with BB_GH_REFRESH=1. Override the TTL with +# BB_GH_CACHE_TTL=. + +# Keep in sync with .github/actions/export-env-vars/action.yml. +_bb_gh_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_ +) + +# Bump if the cache file format changes; older caches are then ignored. +_BB_GH_CACHE_VERSION=1 + +_bb_mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; } + +bb_export_gh_vars() { + local repo="${BB_GH_REPO:-trezor/blockbook}" + local ttl="${BB_GH_CACHE_TTL:-3600}" + local refresh="${BB_GH_REFRESH:-0}" + local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/blockbook" + local cache_file="${cache_dir}/gh-vars-${repo//\//-}.env" + local schema_header="# bb-gh-vars schema ${_BB_GH_CACHE_VERSION}" + + if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: gh CLI is required but not installed (see https://cli.github.com/)." >&2 + return 1 + fi + + if [[ "$refresh" != "1" && -r "$cache_file" ]]; then + local mtime now age header + mtime=$(_bb_mtime "$cache_file") || mtime=0 + now=$(date +%s) + age=$((now - mtime)) + if (( age < ttl )); then + IFS= read -r header < "$cache_file" || header="" + if [[ "$header" == "$schema_header" ]]; then + # shellcheck disable=SC1090 + source "$cache_file" + echo "Loaded BB_* variables from cache (${age}s old, ${cache_file}). Refresh with BB_GH_REFRESH=1." >&2 + return 0 + fi + fi + fi + + # Assumes variable values contain no literal tab/newline — @tsv would escape + # them and the bash `read -r name value` below would split incorrectly. + local raw + if ! raw=$(gh api "/repos/${repo}/actions/variables" --paginate \ + --jq '.variables[] | [.name, .value] | @tsv' 2>&1); then + cat >&2 < "$tmp") || { echo "ERROR: cannot create cache temp file ${tmp}" >&2; return 1; } + printf '%s\n' "$schema_header" >> "$tmp" + + local count=0 name value prefix suffix normalized + while IFS=$'\t' read -r name value; do + [[ -z "$name" ]] && continue + normalized="$name" + for prefix in "${_bb_gh_prefixes[@]}"; do + if [[ "$name" == "$prefix"* ]]; then + suffix="${name#"$prefix"}" + suffix="${suffix//-/_}" + normalized="${prefix}${suffix,,}" + break + fi + done + printf 'export %s=%q\n' "$normalized" "$value" >> "$tmp" + count=$((count + 1)) + done <<< "$raw" + + if [[ $count -eq 0 ]]; then + rm -f "$tmp" + echo "ERROR: ${repo} returned no variables (check 'gh auth status' and Trezor-org membership)." >&2 + return 1 + fi + + mv "$tmp" "$cache_file" + # shellcheck disable=SC1090 + source "$cache_file" + echo "Fetched $count BB_* variables from ${repo}, cached at ${cache_file}." >&2 +} 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..a18a9417f3 --- /dev/null +++ b/contrib/scripts/backend_status.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Query a coin's back-end RPC for sync status. Resolves the URL from +# BB_{DEV,PROD}_RPC_URL_HTTP_ (controlled by BB_BUILD_ENV), fetched +# via gh-vars.sh. UTXO backends additionally need BB_RPC_USER and BB_RPC_PASS. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$script_dir/../gh-vars.sh" + +die() { echo "error: $1" >&2; exit 1; } + +[[ $# -ge 1 ]] || die "usage: backend_status.sh " +coin="$1" + +command -v curl >/dev/null 2>&1 || die "curl is not installed" +command -v jq >/dev/null 2>&1 || die "jq is not installed" + +bb_export_gh_vars + +build_env="${BB_BUILD_ENV:-dev}" +build_env="${build_env,,}" +case "$build_env" in + dev) prefix="BB_DEV_RPC_URL_HTTP_" ;; + prod) prefix="BB_PROD_RPC_URL_HTTP_" ;; + *) die "invalid BB_BUILD_ENV value '$build_env', expected 'dev' or 'prod'" ;; +esac + +# Mirror build/tools/templates.go:aliasCandidates — try , _archive, +# and the infix variant (e.g. `polygon_bor` → `polygon_archive_bor`). +candidates=("$coin" "${coin}_archive") +if [[ "$coin" == *_* && "$coin" != *_archive* ]]; then + infix="${coin%%_*}_archive_${coin#*_}" + [[ "$infix" != "${coin}_archive" ]] && candidates+=("$infix") +fi + +# Mirror build/tools/templates.go:withEnvAliasVariants — for each alias +# candidate, also try the env-var-safe variant with '-' normalized to '_'. +env_candidates=() +for alias in "${candidates[@]}"; do + env_candidates+=("$alias") + normalized_alias="${alias//-/_}" + [[ "$normalized_alias" != "$alias" ]] && env_candidates+=("$normalized_alias") +done + +url="" +for alias in "${env_candidates[@]}"; do + candidate="${prefix}${alias}" + if [[ -n "${!candidate-}" ]]; then + url="${!candidate}" + break + fi +done +[[ -n "$url" ]] || die "no backend RPC URL exported for '${coin}' (tried: ${env_candidates[*]/#/${prefix}})" + +user="${BB_RPC_USER-}" +pass="${BB_RPC_PASS-}" +auth=() +if [[ -n "$user" || -n "$pass" ]]; then + [[ -n "$user" && -n "$pass" ]] || die "set both BB_RPC_USER and BB_RPC_PASS" + auth=(-u "${user}:${pass}") +fi + +rpc() { + local method="$1" params="${2:-[]}" out status body + out=$(curl -skS "${auth[@]}" -H 'content-type: application/json' \ + --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"${method}\",\"params\":${params}}" \ + -w $'\n%{http_code}' "$url") || die "curl failed for ${url}" + status="${out##*$'\n'}" + body="${out%$'\n'*}" + [[ "$status" == "200" ]] || die "${url} returned HTTP ${status} for ${method}: ${body:0:200}" + printf '%s' "$body" +} + +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: ${resp:0:200}" diff --git a/contrib/scripts/blockbook_profile.sh b/contrib/scripts/blockbook_profile.sh new file mode 100755 index 0000000000..daba888f82 --- /dev/null +++ b/contrib/scripts/blockbook_profile.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# Profile a dev Blockbook instance using Go pprof. +# +# Resolves the dev Blockbook API endpoint from BB_DEV_API_URL_HTTP_* variables +# fetched via contrib/gh-vars.sh, then derives the pprof port used by generated +# dev services: ports.blockbook_internal + 20000. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" +source "$script_dir/../gh-vars.sh" + +die() { + echo "error: $1" >&2 + exit 1 +} + +usage() { + cat >&2 <<'EOF' +usage: blockbook_profile.sh [options] + +Options: + --profile pprof profile to collect: cpu, heap, goroutine, allocs, + threadcreate (default: cpu) + --seconds CPU profile duration in seconds (default: 30) + --nodecount number of nodes in go tool pprof -top output (default: 20) + --out-dir directory for downloaded profiles (default: /tmp/blockbook-pprof) + +Examples: + contrib/scripts/blockbook_profile.sh polygon_archive --seconds 30 + contrib/scripts/blockbook_profile.sh polygon_archive --profile goroutine + contrib/scripts/blockbook_profile.sh polygon_archive --profile heap --nodecount 30 + +Profiling is enabled only on dev Blockbooks. The script expects the dev pprof +port to be reachable from the machine where it runs. +EOF + exit 2 +} + +[[ $# -ge 1 ]] || usage +coin="$1" +shift + +profile="cpu" +seconds=30 +nodecount=20 +out_dir="${TMPDIR:-/tmp}/blockbook-pprof" + +while [[ $# -gt 0 ]]; do + case "$1" in + --profile) + [[ $# -ge 2 ]] || die "--profile requires a value" + profile="$2" + shift 2 + ;; + --seconds) + [[ $# -ge 2 ]] || die "--seconds requires a value" + seconds="$2" + shift 2 + ;; + --nodecount) + [[ $# -ge 2 ]] || die "--nodecount requires a value" + nodecount="$2" + shift 2 + ;; + --out-dir) + [[ $# -ge 2 ]] || die "--out-dir requires a value" + out_dir="$2" + shift 2 + ;; + -h|--help) + usage + ;; + *) + die "unknown argument: $1" + ;; + esac +done + +case "$profile" in + cpu|heap|goroutine|allocs|threadcreate) ;; + *) die "unsupported profile '$profile'" ;; +esac + +[[ "$seconds" =~ ^[0-9]+$ && "$seconds" -ge 1 ]] || die "--seconds must be a positive integer" +[[ "$nodecount" =~ ^[0-9]+$ && "$nodecount" -ge 1 ]] || die "--nodecount must be a positive integer" + +command -v curl >/dev/null 2>&1 || die "curl is not installed" +command -v jq >/dev/null 2>&1 || die "jq is not installed" +command -v python3 >/dev/null 2>&1 || die "python3 is not installed" +command -v go >/dev/null 2>&1 || die "go is not installed; go tool pprof is required" + +bb_export_gh_vars +export BB_BUILD_ENV="${BB_BUILD_ENV:-dev}" + +config_path="$repo_root/configs/coins/${coin}.json" +[[ -f "$config_path" ]] || die "missing coin config: $config_path" + +resolver_output="$( + python3 - "$repo_root" "$coin" <<'PY' +import json +import os +import shlex +import sys +from pathlib import Path +from urllib.parse import urlparse, urlunparse + +repo_root = Path(sys.argv[1]) +coin = sys.argv[2] +cfg = json.loads((repo_root / "configs" / "coins" / f"{coin}.json").read_text()) + +test_identity = (cfg.get("coin", {}).get("test_name") or coin).strip() +alias = (cfg.get("coin", {}).get("alias") or coin).strip() +ports = cfg.get("ports", {}) +public_port = int(ports.get("blockbook_public") or 0) +internal_port = int(ports.get("blockbook_internal") or 0) +if internal_port <= 0: + raise SystemExit("missing ports.blockbook_internal") + +# Mirror tests/endpoints/endpoints.go:resolveAPIEndpoints — resolve the dev API +# base from BB_DEV_API_URL_HTTP_, then the '-'->'_' env variant, so +# this targets the same Blockbook instance the integration tests reach. +candidates = [] +for candidate in (test_identity, test_identity.replace("-", "_")): + if candidate and candidate not in candidates: + candidates.append(candidate) + +base_url = "" +source_var = "" +for candidate in candidates: + key = f"BB_DEV_API_URL_HTTP_{candidate}" + value = os.environ.get(key, "").strip() + if value: + base_url = value + source_var = key + break + +if not base_url: + if public_port <= 0: + raise SystemExit("no BB_DEV_API_URL_HTTP_* value and missing ports.blockbook_public") + base_url = f"http://127.0.0.1:{public_port}" + source_var = "configs/coins fallback" + +parsed = urlparse(base_url) +if parsed.scheme not in ("http", "https") or not parsed.hostname: + raise SystemExit(f"invalid Blockbook HTTP URL from {source_var}: {base_url!r}") + +host = parsed.hostname +if ":" in host and not host.startswith("["): + host_for_url = f"[{host}]" +else: + host_for_url = host + +pprof_port = internal_port + 20000 +pprof_base = f"http://{host_for_url}:{pprof_port}/debug/pprof" +status_url = urlunparse((parsed.scheme, parsed.netloc, parsed.path.rstrip("/") + "/api/status", "", "", "")) +metrics_https = f"https://{host_for_url}:{internal_port}/metrics" +metrics_http = f"http://{host_for_url}:{internal_port}/metrics" + +values = { + "BBP_COIN": coin, + "BBP_TEST_IDENTITY": test_identity, + "BBP_ALIAS": alias, + "BBP_SOURCE_VAR": source_var, + "BBP_BASE_URL": base_url, + "BBP_STATUS_URL": status_url, + "BBP_METRICS_HTTPS": metrics_https, + "BBP_METRICS_HTTP": metrics_http, + "BBP_PPROF_BASE": pprof_base, + "BBP_PPROF_PORT": str(pprof_port), +} +for key, value in values.items(): + print(f"{key}={shlex.quote(value)}") +PY +)" || die "failed to resolve dev Blockbook/pprof endpoint for ${coin}" +eval "$resolver_output" + +fetch_with_status() { + local url="$1" + local out + out="$(curl -sk --max-time 10 -w $'\n%{http_code}' "$url")" || return 1 + FETCH_STATUS="${out##*$'\n'}" + FETCH_BODY="${out%$'\n'*}" +} + +echo "coin: ${BBP_COIN} (test=${BBP_TEST_IDENTITY}, alias=${BBP_ALIAS})" +echo "dev endpoint: ${BBP_BASE_URL} (${BBP_SOURCE_VAR})" +echo "pprof endpoint: ${BBP_PPROF_BASE}/" +echo + +FETCH_STATUS="" +FETCH_BODY="" +if fetch_with_status "$BBP_STATUS_URL"; then + if [[ "$FETCH_STATUS" == "400" && "${FETCH_BODY,,}" == *"http request to an https server"* ]]; then + BBP_STATUS_URL="${BBP_STATUS_URL/#http:/https:}" + fetch_with_status "$BBP_STATUS_URL" || true + fi +fi + +if [[ "$FETCH_STATUS" == "200" ]]; then + echo "status snapshot:" + printf '%s' "$FETCH_BODY" | jq -r ' + . as $status + | $status.blockbook as $bb + | $status.backend as $be + | " sync: inSync=\($bb.inSync) initialSync=\($bb.initialSync) syncMode=\($bb.syncMode) blockbookHeight=\($bb.bestHeight) backendHeight=\($be.blocks // $be.bestHeight // "n/a")" + , " mempool: inSync=\($bb.inSyncMempool) size=\($bb.mempoolSize) lastMempoolTime=\($bb.lastMempoolTime)" + , " last block: \($bb.lastBlockTime)"' || echo " (status snapshot body was not valid JSON)" >&2 +else + echo "status snapshot: unavailable from ${BBP_STATUS_URL}${FETCH_STATUS:+ (HTTP ${FETCH_STATUS})}" >&2 +fi + +metrics_body="" +for metrics_url in "$BBP_METRICS_HTTPS" "$BBP_METRICS_HTTP"; do + if fetch_with_status "$metrics_url" && [[ "$FETCH_STATUS" == "200" ]]; then + metrics_body="$FETCH_BODY" + break + fi +done + +if [[ -n "$metrics_body" ]]; then + echo "metrics snapshot:" + awk ' + /^blockbook_average_block_time_seconds\{/ { avg_target=$NF } + /^blockbook_avg_block_period\{/ { avg_actual=$NF } + /^blockbook_backend_best_height\{/ { backend_height=$NF } + /^blockbook_best_height\{/ { blockbook_height=$NF } + /^blockbook_backend_subscription_age_seconds\{/ { sub_age=$NF } + /^blockbook_mempool_size\{/ { mempool_size=$NF } + /^blockbook_mempool_resync_duration_sum\{/ { mempool_resync_sum=$NF } + /^blockbook_mempool_resync_duration_count\{/ { mempool_resync_count=$NF } + END { + if (blockbook_height != "" || backend_height != "") { + lag = (backend_height == "" || blockbook_height == "") ? "n/a" : sprintf("%.0f", backend_height - blockbook_height) + printf " heights: blockbook=%.0f backend=%.0f lag=%s\n", blockbook_height, backend_height, lag + } + if (avg_target != "" || avg_actual != "" || sub_age != "") { + printf " chain feed: configured_block_time=%ss observed_100_block_period=%ss subscription_age=%ss\n", avg_target, avg_actual, sub_age + } + if (mempool_size != "") { + avg = (mempool_resync_count > 0) ? sprintf("%.1fms", mempool_resync_sum / mempool_resync_count) : "n/a" + printf " mempool: size=%.0f resync_count=%.0f avg_resync_duration=%s\n", mempool_size, mempool_resync_count, avg + } + }' <<< "$metrics_body" +else + echo "metrics snapshot: unavailable from ${BBP_METRICS_HTTPS} or ${BBP_METRICS_HTTP}" >&2 +fi +echo + +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +safe_coin="${coin//[^A-Za-z0-9_.-]/_}" +mkdir -p "$out_dir" +profile_file="${out_dir}/${safe_coin}_${profile}_${timestamp}.pb.gz" + +case "$profile" in + cpu) + profile_url="${BBP_PPROF_BASE}/profile?seconds=${seconds}" + max_time=$((seconds + 30)) + ;; + *) + profile_url="${BBP_PPROF_BASE}/${profile}" + max_time=30 + ;; +esac + +echo "downloading ${profile} profile: ${profile_url}" +if ! curl -fsS --max-time "$max_time" "$profile_url" -o "$profile_file"; then + die "failed to download profile from ${profile_url}; profiling is enabled only on dev Blockbooks, so check that the service has -prof and port ${BBP_PPROF_PORT} is reachable" +fi +echo "saved profile: ${profile_file}" +echo +echo "go tool pprof -top:" +go tool pprof -top "-nodecount=${nodecount}" "$profile_file" diff --git a/contrib/scripts/blockbook_status.sh b/contrib/scripts/blockbook_status.sh new file mode 100755 index 0000000000..88a8eb16a0 --- /dev/null +++ b/contrib/scripts/blockbook_status.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Hit the /api/status endpoint of a Blockbook instance for a given coin. +# Resolves the URL from BB_DEV_API_URL_HTTP_, fetched via gh-vars.sh. +# Retries with https:// if the http:// endpoint responds with the nginx +# "HTTP request to HTTPS server" 400 — matches what the connectivity +# integration test does (tests/connectivity/blockbook_connectivity.go). +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$script_dir/../gh-vars.sh" + +die() { echo "error: $1" >&2; exit 1; } +[[ $# -eq 1 ]] || die "usage: blockbook_status.sh " +coin="$1" + +command -v curl >/dev/null 2>&1 || die "curl is not installed" +command -v jq >/dev/null 2>&1 || die "jq is not installed" + +bb_export_gh_vars + +# Mirror build/tools/templates.go:withEnvAliasVariants — also try the env-var-safe +# variant with '-' normalized to '_' (gh-vars.sh exports BB_* names with this form). +var="BB_DEV_API_URL_HTTP_${coin}" +base_url="${!var-}" +if [[ -z "$base_url" ]]; then + normalized_coin="${coin//-/_}" + if [[ "$normalized_coin" != "$coin" ]]; then + var="BB_DEV_API_URL_HTTP_${normalized_coin}" + base_url="${!var-}" + fi +fi +[[ -n "$base_url" ]] || die "no Blockbook URL exported for '${coin}' (BB_DEV_API_URL_HTTP_${coin} not set)" + +# Curl with response body and status; pure-bash split on the trailing newline. +fetch() { + local out + out=$(curl -sk --max-time 10 -w $'\n%{http_code}' "$1") || die "curl failed for $1" + status="${out##*$'\n'}" + body="${out%$'\n'*}" +} + +status=""; body="" +status_url="${base_url%/}/api/status" +fetch "$status_url" + +if [[ "$status" == "400" && "${body,,}" == *'http request to an https server'* ]]; then + status_url="${status_url/#http:/https:}" + fetch "$status_url" +fi + +[[ "$status" == "200" ]] || die "GET $status_url returned HTTP $status: ${body:0:200}" +printf '%s' "$body" | 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..a77a70e452 --- /dev/null +++ b/contrib/scripts/deploy-blockbook-local.sh @@ -0,0 +1,107 @@ +#!/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 "reloading systemd manager configuration" +sudo systemctl daemon-reload + +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..f634b44aa0 100755 --- a/contrib/scripts/deploy-dev.sh +++ b/contrib/scripts/deploy-dev.sh @@ -1,40 +1,46 @@ #!/usr/bin/env bash +set -euo pipefail + +prog="$(basename "$(readlink -f "$0")")" if [ $# -lt 2 ] then - echo "Usage: $(basename $(readlink -f $0)) hostname coin [...]" 1>&2 + echo "Usage: ${prog} hostname coin [...]" 1>&2 exit 1 fi -HOST=$1 +HOST="$1" shift -COINS=$@ +COINS=("$@") -REPO=$(cd $(dirname $(readlink -f $0)) && git rev-parse --show-toplevel) -UPDATE_VENDOR=${UPDATE_VENDOR:0} +REPO="$(cd "$(dirname "$(readlink -f "$0")")" && git rev-parse --show-toplevel)" +UPDATE_VENDOR="${UPDATE_VENDOR:-0}" -cd ${REPO} +cd "${REPO}" -VERSION=$(cd build/deb && dpkg-parsechangelog | sed -rne 's/^Version: ([0-9.]+)([-+~].+)?$/\1/p') +VERSION="$(cd build/deb && dpkg-parsechangelog | sed -rne 's/^Version: ([0-9.]+)([-+~].+)?$/\1/p')" -make deb UPDATE_VENDOR=${UPDATE_VENDOR} || exit $? +make deb "UPDATE_VENDOR=${UPDATE_VENDOR}" -echo -e "\nDeploying: $@\n" +echo -e "\nDeploying: ${COINS[*]}\n" status=0 -for coin in $COINS +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" \ - || status=$? + coin_status=0 + pkg="build/blockbook-${coin}_${VERSION}_amd64.deb" + scp "${pkg}" "${HOST}:" \ + && ssh "${HOST}" "pkg=\$PWD/blockbook-${coin}_${VERSION}_amd64.deb && sudo DEBIAN_FRONTEND=noninteractive apt install -y --reinstall \"\$pkg\" && sudo systemctl restart blockbook-${coin}.service" \ + || coin_status=$? - if [ ${status} == 0 ] + if [ "${coin_status}" = 0 ] then echo -e "\nOK - ${coin} deployed" else - echo -e "\nFAIL - ${coin} status: ${status}" + status="${coin_status}" + echo -e "\nFAIL - ${coin} status: ${coin_status}" fi echo @@ -44,4 +50,4 @@ make clean echo -e "\nDONE" -exit ${status} +exit "${status}" diff --git a/contrib/scripts/render_grafana.py b/contrib/scripts/render_grafana.py new file mode 100644 index 0000000000..c800b20bd5 --- /dev/null +++ b/contrib/scripts/render_grafana.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""Render the Blockbook Grafana dashboard from its three sources. + + configs/grafana/template.json structure/viz skeleton (no per-panel content) + configs/grafana/panels.yaml per-panel content: title, description, queries + configs/metrics.yaml metric registry (name/help per stable key) + | + v + configs/grafana/grafana.json the artifact you import into Grafana (NOT committed) + +template.json carries a semantic `x-panel-key` on each panel and a semantic `x-query-key` +on each target (alongside Grafana's own `id`/`refId`). It holds no `gridPos` and no +`datasource`: panel x/y are packed from panel order, w/h come from panels.yaml (optional +`width`/`height`, defaulting to a full-width row or an 8x8 cell), and the single Prometheus +datasource is injected at render time. panels.yaml is keyed by `x-panel-key`, and its +`queries:` are keyed by `x-query-key`. For each panel this overlays its title/description +and, per query, its `promql`/`legend` (-> the target's expr/legendFormat). Inside those +strings, {{name:}} and {{help:}} are expanded from configs/metrics.yaml, so a +metric's prometheus name or help is single-sourced and a rename propagates everywhere. The +x-keys are stripped from the rendered grafana.json, which stays pure Grafana. + + python3 contrib/scripts/render_grafana.py [--check] + + --check validate + render in memory and exit non-zero on any problem + (unknown metric key, panel/query key mismatch, residual placeholder, or a + rendered dashboard that violates a Grafana import invariant -- see + validate_rendered) WITHOUT writing the file -- for CI / pre-commit. +""" +import json +import os +import re +import sys + +import yaml + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +METRICS = os.path.join(REPO, "configs", "metrics.yaml") +TEMPLATE = os.path.join(REPO, "configs", "grafana", "template.json") +PANELS = os.path.join(REPO, "configs", "grafana", "panels.yaml") +OUTPUT = os.path.join(REPO, "configs", "grafana", "grafana.json") + +PLACEHOLDER = re.compile(r"\{\{\s*(name|help):([a-z0-9_]+)\s*\}\}") +GRID_COLUMNS = 24 +DEFAULT_PANEL_WIDTH = 8 +DEFAULT_PANEL_HEIGHT = 8 +# Single datasource for the whole dashboard -- injected at render time so the template +# carries no per-panel/per-target datasource boilerplate. +DATASOURCE = {"type": "prometheus", "uid": "${DS_PROMETHEUS}"} + + +def fail(msg): + sys.exit("error: " + msg) + + +def load_registry(): + with open(METRICS) as f: + cfg = yaml.safe_load(f) + return {key: {"name": d["name"], "help": d.get("help", "")} for key, d in cfg["metrics"].items()} + + +def load_panels(): + # keyed by the semantic x-panel-key in template.json + with open(PANELS) as f: + return yaml.safe_load(f)["panels"] + + +def expand(value, reg, missing): + def repl(m): + field, key = m.group(1), m.group(2) + if key not in reg: + missing.add(key) + return m.group(0) + return reg[key][field] + + return PLACEHOLDER.sub(repl, value) + + +def iter_panels(panels): + for p in panels: + yield p + yield from iter_panels(p.get("panels", [])) + + +def panel_label(panel): + return panel.get("x-panel-key") or panel.get("id") or "" + + +def is_positive_int(value): + return isinstance(value, int) and not isinstance(value, bool) and value > 0 + + +def layout_items_from_panels(panels, content, columns=GRID_COLUMNS): + """Return side-effect-free layout hints for a Grafana panel list. + + Per-panel `width`/`height` come from the matching panels.yaml entry (looked up by + x-panel-key); panels omit them and fall back to a full-width row or a DEFAULT_PANEL + cell. template.json carries no gridPos at all -- x/y are packed during rendering. + """ + items = [] + problems = [] + for panel in panels: + label = panel_label(panel) + ptype = panel.get("type") + is_row = ptype == "row" + + if "gridPos" in panel: + problems.append("template panel %s must not carry gridPos; set width/height in panels.yaml " + "(x/y are computed by render_grafana.py)" % label) + + entry = content.get(panel.get("x-panel-key")) or {} + width = entry.get("width", columns if is_row else DEFAULT_PANEL_WIDTH) + height = entry.get("height", 1 if is_row else DEFAULT_PANEL_HEIGHT) + if not is_positive_int(width): + problems.append("panel %s 'width' must be a positive integer in panels.yaml" % label) + if not is_positive_int(height): + problems.append("panel %s 'height' must be a positive integer in panels.yaml" % label) + if is_positive_int(width) and width > columns: + problems.append("panel %s width=%d exceeds Grafana's %d-column grid" % (label, width, columns)) + + items.append({"key": label, "type": ptype, "w": width, "h": height}) + return items, problems + + +def compute_grid_positions(items, columns=GRID_COLUMNS): + """Pack layout items left-to-right into Grafana gridPos dictionaries.""" + positions = [] + problems = [] + x = 0 + y = 0 + row_height = 0 + + for item in items: + key = item["key"] + width = item["w"] + height = item["h"] + if not is_positive_int(width) or not is_positive_int(height): + problems.append("panel %s has invalid layout dimensions" % key) + continue + if width > columns: + problems.append("panel %s width %d exceeds Grafana's %d-column grid" % (key, width, columns)) + continue + + if item["type"] == "row": + if x: + y += row_height + x = 0 + row_height = 0 + positions.append({"h": height, "w": width, "x": 0, "y": y}) + y += height + continue + + if x and x + width > columns: + y += row_height + x = 0 + row_height = 0 + + positions.append({"h": height, "w": width, "x": x, "y": y}) + x += width + row_height = max(row_height, height) + if x == columns: + y += row_height + x = 0 + row_height = 0 + + return positions, problems + + +def apply_computed_grid_positions(panels, content, columns=GRID_COLUMNS): + """Apply computed gridPos to a panel list after pure validation/packing.""" + items, problems = layout_items_from_panels(panels, content, columns) + if problems: + return problems + + positions, problems = compute_grid_positions(items, columns) + if problems: + return problems + if len(positions) != len(panels): + return ["computed %d grid positions for %d template panels" % (len(positions), len(panels))] + + for panel, grid_pos in zip(panels, positions): + panel["gridPos"] = grid_pos + problems.extend(apply_computed_grid_positions(panel.get("panels", []), content, columns)) + return problems + + +def is_nonneg_int(value): + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def validate_rendered(dash, columns=GRID_COLUMNS): + """Assert the structural invariants Grafana enforces only at import time. + + The render can be internally consistent (every panel keyed, every placeholder resolved) + yet still produce a dashboard Grafana silently refuses to import: a duplicate panel id, a + panel packed past the 24-column grid, two panels overlapping, a target with no datasource, + or a `${input}` that no `__inputs` entry declares. Grafana reports these only as an opaque + import failure, so check them here and fail the render with a precise message instead. + """ + problems = [] + + sv = dash.get("schemaVersion") + if not isinstance(sv, int) or isinstance(sv, bool): + problems.append("dashboard schemaVersion must be an integer (got %r)" % (sv,)) + + # every ${VAR} in the serialized dashboard must resolve to an __inputs entry (an import-time + # datasource/constant) or a templating variable -- an undeclared one fails import. + declared = {i.get("name") for i in dash.get("__inputs", []) if isinstance(i, dict)} + tmpl_vars = {v.get("name") for v in dash.get("templating", {}).get("list", []) if isinstance(v, dict)} + referenced = set(re.findall(r"\$\{([A-Za-z0-9_]+)(?::[A-Za-z0-9_]+)?\}", json.dumps(dash))) + for var in sorted(referenced - declared - tmpl_vars): + problems.append("dashboard references ${%s} but no __inputs entry or templating variable declares it" % var) + + seen_ids = {} + + def check_level(panels, where): + rects = [] + for panel in panels: + label = panel.get("title") or panel.get("id") or "" + pid = panel.get("id") + if not is_nonneg_int(pid): + problems.append("%s panel %r has a non-integer id (%r)" % (where, label, pid)) + elif pid in seen_ids: + problems.append("panel id %d is shared by %r and %r" % (pid, seen_ids[pid], label)) + else: + seen_ids[pid] = label + + grid = panel.get("gridPos") + if not isinstance(grid, dict): + problems.append("%s panel %r has no gridPos" % (where, label)) + grid = {} + w, h, x, y = grid.get("w"), grid.get("h"), grid.get("x"), grid.get("y") + if not (is_positive_int(w) and is_positive_int(h)): + problems.append("%s panel %r gridPos needs positive integer w/h (got %r)" % (where, label, grid)) + if not (is_nonneg_int(x) and is_nonneg_int(y)): + problems.append("%s panel %r gridPos needs non-negative integer x/y (got %r)" % (where, label, grid)) + if is_positive_int(w) and is_nonneg_int(x) and x + w > columns: + problems.append("%s panel %r spans past the %d-column grid (x=%d w=%d)" % (where, label, columns, x, w)) + if all(is_nonneg_int(v) for v in (x, y)) and is_positive_int(w) and is_positive_int(h): + rects.append((x, y, w, h, label)) + + if panel.get("type") == "row": + if not isinstance(panel.get("collapsed"), bool): + problems.append("%s row %r must carry a boolean 'collapsed'" % (where, label)) + if not isinstance(panel.get("panels"), list): + problems.append("%s row %r must carry a 'panels' list" % (where, label)) + else: + ds = panel.get("datasource") + if not (isinstance(ds, dict) and ds.get("type") and ds.get("uid")): + problems.append("%s panel %r must carry a datasource with type+uid" % (where, label)) + for target in panel.get("targets", []): + tds = target.get("datasource") + if not (isinstance(tds, dict) and tds.get("type") and tds.get("uid")): + problems.append("%s panel %r target %r must carry a datasource with type+uid" + % (where, label, target.get("refId"))) + + # panels in one container must not overlap -- Grafana would render them stacked + for i in range(len(rects)): + ax, ay, aw, ah, al = rects[i] + for j in range(i + 1, len(rects)): + bx, by, bw, bh, bl = rects[j] + if not (ax + aw <= bx or bx + bw <= ax or ay + ah <= by or by + bh <= ay): + problems.append("%s panels %r and %r overlap" % (where, al, bl)) + + for panel in panels: + if panel.get("type") == "row" and panel.get("panels"): + check_level(panel["panels"], "row %r >" % (panel.get("title") or panel.get("id"))) + + check_level(dash.get("panels", []), "top-level") + return problems + + +def render(): + reg = load_registry() + panels = load_panels() + with open(TEMPLATE) as f: + dash = json.load(f) + + missing = set() # unknown metric keys referenced by a placeholder + problems = [] # structural mismatches + used_keys = set() + seen_panel_keys = set() + + problems.extend(apply_computed_grid_positions(dash["panels"], panels)) + + for panel in iter_panels(dash["panels"]): + pid = panel.get("id") + pkey = panel.get("x-panel-key") + # purity: the template must carry no blockbook content -- it lives in panels.yaml + for f in ("title", "description"): + if f in panel: + problems.append("template panel %s must not contain %r (it belongs in panels.yaml)" % (pkey or pid, f)) + # datasource is injected at render time -- it must not be pinned in the template + if "datasource" in panel: + problems.append("template panel %s must not contain 'datasource' (it is injected at render time)" % (pkey or pid)) + for t in panel.get("targets", []): + for f in ("expr", "legendFormat", "datasource"): + if f in t: + problems.append("template panel %s target %s must not contain %r (it belongs in panels.yaml or is injected)" + % (pkey or pid, t.get("x-query-key") or t.get("refId"), f)) + if pkey is None: + problems.append("template panel id %s has no x-panel-key" % pid) + continue + if pkey in seen_panel_keys: + problems.append("template panel key %r is duplicated" % pkey) + continue + seen_panel_keys.add(pkey) + entry = panels.get(pkey) + if entry is None: + problems.append("template panel %r (id %s) has no entry in panels.yaml" % (pkey, pid)) + continue + used_keys.add(pkey) + + if "title" in entry: + panel["title"] = expand(entry["title"], reg, missing) + if "description" in entry: + panel["description"] = expand(entry["description"], reg, missing) + + queries = entry.get("queries", {}) + tmpl_qkeys = set() + for t in panel.get("targets", []): + qkey = t.get("x-query-key") + if qkey is None: + problems.append("panel %r target refId %r has no x-query-key" % (pkey, t.get("refId"))) + continue + if qkey in tmpl_qkeys: + problems.append("panel %r query key %r is duplicated in template targets" % (pkey, qkey)) + continue + tmpl_qkeys.add(qkey) + qc = queries.get(qkey) + if qc is None: + problems.append("panel %r target %r (refId %s) has no query in panels.yaml" + % (pkey, qkey, t.get("refId"))) + continue + promql = qc.get("promql") + if promql is None: + problems.append("panel %r query %r (refId %s) has no 'promql' in panels.yaml" + % (pkey, qkey, t.get("refId"))) + continue + t["expr"] = expand(promql, reg, missing) + if "legend" in qc: + t["legendFormat"] = expand(qc["legend"], reg, missing) + extra = set(queries) - tmpl_qkeys + if extra: + problems.append("panel %r panels.yaml has query key(s) %s absent from the template" % (pkey, sorted(extra))) + + orphans = set(panels) - used_keys + if orphans: + problems.append("panels.yaml has entries for x-panel-keys not in template.json: %s" % sorted(orphans)) + if missing: + problems.append("unknown metric key(s) referenced: %s" % sorted(missing)) + + # x-panel-key / x-query-key are render-time join keys, not Grafana fields -- strip them so + # grafana.json is pure Grafana. (After validation, which needs them; before serialization.) + # The single datasource is injected here onto every non-row panel and every target. + for panel in iter_panels(dash["panels"]): + panel.pop("x-panel-key", None) + if panel.get("type") != "row": + panel["datasource"] = dict(DATASOURCE) + for t in panel.get("targets", []): + t["datasource"] = dict(DATASOURCE) + t.pop("x-query-key", None) + + # belt-and-suspenders: nothing in our namespace should survive + residual = PLACEHOLDER.findall(json.dumps(dash)) + if residual: + problems.append("unresolved {{name|help:...}} placeholder(s) remain: %s" % residual[:5]) + + # final gate: the rendered dashboard must satisfy the invariants Grafana checks at import + problems.extend(validate_rendered(dash)) + + if problems: + fail("dashboard render failed:\n - " + "\n - ".join(problems)) + + return dash, reg, panels + + +def main(): + check = "--check" in sys.argv[1:] + dash, reg, panels = render() + text = json.dumps(dash, indent=2, ensure_ascii=False) + "\n" + + if check: + print("OK: render is consistent (%d panels, %d metrics, no unresolved keys)" % (len(panels), len(reg))) + return + + with open(OUTPUT, "w") as f: + f.write(text) + print("rendered %s" % os.path.relpath(OUTPUT, REPO)) + print(" from template + panels.yaml (%d panels) + metrics.yaml (%d metrics)" % (len(panels), len(reg))) + + +if __name__ == "__main__": + main() diff --git a/contrib/scripts/render_grafana_test.py b/contrib/scripts/render_grafana_test.py new file mode 100644 index 0000000000..9391ea2c9b --- /dev/null +++ b/contrib/scripts/render_grafana_test.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) + +import render_grafana + + +class GrafanaLayoutTest(unittest.TestCase): + def test_compute_grid_positions_packs_left_to_right(self): + items = [ + {"key": "row.websocket", "type": "row", "w": 24, "h": 1}, + {"key": "websocket.clients", "type": "timeseries", "w": 8, "h": 8}, + {"key": "websocket.requests", "type": "timeseries", "w": 8, "h": 8}, + {"key": "websocket.subscriptions", "type": "timeseries", "w": 8, "h": 8}, + {"key": "websocket.rejections", "type": "timeseries", "w": 12, "h": 8}, + ] + + positions, problems = render_grafana.compute_grid_positions(items) + + self.assertEqual([], problems) + self.assertEqual( + [ + {"h": 1, "w": 24, "x": 0, "y": 0}, + {"h": 8, "w": 8, "x": 0, "y": 1}, + {"h": 8, "w": 8, "x": 8, "y": 1}, + {"h": 8, "w": 8, "x": 16, "y": 1}, + {"h": 8, "w": 12, "x": 0, "y": 9}, + ], + positions, + ) + + def test_compute_grid_positions_wraps_by_tallest_panel_in_row(self): + items = [ + {"key": "left", "type": "timeseries", "w": 12, "h": 4}, + {"key": "right", "type": "timeseries", "w": 12, "h": 8}, + {"key": "next", "type": "timeseries", "w": 8, "h": 4}, + ] + + positions, problems = render_grafana.compute_grid_positions(items) + + self.assertEqual([], problems) + self.assertEqual( + [ + {"h": 4, "w": 12, "x": 0, "y": 0}, + {"h": 8, "w": 12, "x": 12, "y": 0}, + {"h": 4, "w": 8, "x": 0, "y": 8}, + ], + positions, + ) + + def test_layout_items_reads_sizes_from_panels_yaml_with_defaults(self): + # a row and a default-sized panel omit width/height; a wide panel overrides width. + panels = [ + {"x-panel-key": "row.general", "type": "row"}, + {"x-panel-key": "general.tip_age", "type": "timeseries"}, + {"x-panel-key": "websocket.unique_ips", "type": "timeseries"}, + ] + content = {"websocket.unique_ips": {"width": 12, "title": "..."}} + + items, problems = render_grafana.layout_items_from_panels(panels, content) + + self.assertEqual([], problems) + self.assertEqual( + [ + {"key": "row.general", "type": "row", "h": 1, "w": 24}, + {"key": "general.tip_age", "type": "timeseries", "h": 8, "w": 8}, + {"key": "websocket.unique_ips", "type": "timeseries", "h": 8, "w": 12}, + ], + items, + ) + # side-effect-free: layout extraction must not stamp gridPos onto the template panels + self.assertNotIn("gridPos", panels[1]) + + def test_layout_items_rejects_committed_gridpos(self): + panels = [ + { + "x-panel-key": "general.tip_age", + "type": "timeseries", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 1}, + }, + ] + + _, problems = render_grafana.layout_items_from_panels(panels, {}) + + self.assertEqual( + [ + "template panel general.tip_age must not carry gridPos; set width/height in panels.yaml " + "(x/y are computed by render_grafana.py)", + ], + problems, + ) + + def test_layout_items_rejects_oversized_width(self): + panels = [{"x-panel-key": "wide", "type": "timeseries"}] + content = {"wide": {"width": 25}} + + _, problems = render_grafana.layout_items_from_panels(panels, content) + + self.assertEqual(["panel wide width=25 exceeds Grafana's 24-column grid"], problems) + + def test_apply_computed_grid_positions_packs_mixed_widths(self): + panels = [ + {"x-panel-key": "general.synchronized", "type": "stat"}, + {"x-panel-key": "general.block_height", "type": "stat"}, + {"x-panel-key": "websocket.unique_ips", "type": "timeseries"}, + ] + content = {"websocket.unique_ips": {"width": 12}} + + problems = render_grafana.apply_computed_grid_positions(panels, content) + + self.assertEqual([], problems) + self.assertEqual({"h": 8, "w": 8, "x": 0, "y": 0}, panels[0]["gridPos"]) + self.assertEqual({"h": 8, "w": 8, "x": 8, "y": 0}, panels[1]["gridPos"]) + # 8 + 8 + 12 > 24, so the wide panel wraps to the next shelf + self.assertEqual({"h": 8, "w": 12, "x": 0, "y": 8}, panels[2]["gridPos"]) + + +def _valid_rendered_dash(): + ds = {"type": "prometheus", "uid": "${DS_PROMETHEUS}"} + return { + "schemaVersion": 41, + "__inputs": [{"name": "DS_PROMETHEUS", "type": "datasource"}], + "templating": {"list": [{"name": "coin", "type": "custom"}]}, + "panels": [ + {"id": 1, "type": "row", "collapsed": False, "panels": [], + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 0}}, + {"id": 2, "type": "timeseries", "datasource": ds, + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 1}, + "targets": [{"refId": "A", "datasource": ds, "expr": 'up{coin="$coin"}'}]}, + {"id": 3, "type": "timeseries", "datasource": ds, + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 1}, + "targets": [{"refId": "A", "datasource": ds, "expr": "up"}]}, + ], + } + + +class GrafanaImportValidityTest(unittest.TestCase): + def test_valid_dashboard_has_no_problems(self): + self.assertEqual([], render_grafana.validate_rendered(_valid_rendered_dash())) + + def test_duplicate_panel_id_is_rejected(self): + dash = _valid_rendered_dash() + dash["panels"][2]["id"] = 2 + self.assertTrue(any("shared by" in p for p in render_grafana.validate_rendered(dash))) + + def test_overlapping_panels_are_rejected(self): + dash = _valid_rendered_dash() + dash["panels"][2]["gridPos"]["x"] = 0 # now sits on top of panel id 2 + self.assertTrue(any("overlap" in p for p in render_grafana.validate_rendered(dash))) + + def test_panel_past_the_grid_is_rejected(self): + dash = _valid_rendered_dash() + dash["panels"][2]["gridPos"]["x"] = 20 # 20 + 8 > 24 + self.assertTrue(any("past the 24-column grid" in p for p in render_grafana.validate_rendered(dash))) + + def test_target_without_datasource_is_rejected(self): + dash = _valid_rendered_dash() + dash["panels"][1]["targets"][0].pop("datasource") + problems = render_grafana.validate_rendered(dash) + self.assertTrue(any("target" in p and "datasource" in p for p in problems)) + + def test_undeclared_input_variable_is_rejected(self): + dash = _valid_rendered_dash() + dash["__inputs"] = [] # ${DS_PROMETHEUS} now resolves to nothing + self.assertTrue(any("DS_PROMETHEUS" in p for p in render_grafana.validate_rendered(dash))) + + def test_row_without_panels_list_is_rejected(self): + dash = _valid_rendered_dash() + dash["panels"][0].pop("panels") + self.assertTrue(any("'panels' list" in p for p in render_grafana.validate_rendered(dash))) + + +if __name__ == "__main__": + unittest.main() 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/contrib/system-check.sh b/contrib/system-check.sh new file mode 100755 index 0000000000..1bf3cfd27d --- /dev/null +++ b/contrib/system-check.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Preflight checks for local Blockbook test wrappers. +set -euo pipefail + +usage() { + cat >&2 <&2 + status=1 +} + +require_env() { + local name="$1" + local example="$2" + + if [[ -z "${!name:-}" ]]; then + fail "${name} is not exported. Example: export ${name}=\"${example}\"" + fi +} + +include_dirs_from_cflags() { + local previous="" + local flag + + for flag in "${cflags[@]}"; do + if [[ "$previous" == "-I" ]]; then + echo "$flag" + previous="" + continue + fi + if [[ "$flag" == "-I" ]]; then + previous="-I" + continue + fi + if [[ "$flag" == -I* ]]; then + echo "${flag#-I}" + fi + done +} + +library_dirs_from_ldflags() { + local previous="" + local flag + + for flag in "${ldflags[@]}"; do + if [[ "$previous" == "-L" ]]; then + echo "$flag" + previous="" + continue + fi + if [[ "$flag" == "-L" ]]; then + previous="-L" + continue + fi + if [[ "$flag" == -L* ]]; then + echo "${flag#-L}" + fi + done +} + +check_rocksdb_header() { + local dir + + while IFS= read -r dir; do + [[ -n "$dir" ]] || continue + if [[ -r "$dir/rocksdb/c.h" ]]; then + return 0 + fi + done < <(include_dirs_from_cflags) + + fail "rocksdb/c.h was not found in any CGO_CFLAGS -I path." +} + +check_rocksdb_library() { + local dir found_lrocksdb=0 + + for flag in "${ldflags[@]}"; do + [[ "$flag" == "-lrocksdb" ]] && found_lrocksdb=1 + done + if [[ "$found_lrocksdb" -ne 1 ]]; then + fail "CGO_LDFLAGS must include -lrocksdb." + return + fi + + while IFS= read -r dir; do + [[ -n "$dir" ]] || continue + if compgen -G "$dir/librocksdb.so*" >/dev/null || [[ -r "$dir/librocksdb.a" ]]; then + return 0 + fi + done < <(library_dirs_from_ldflags) + + fail "librocksdb was not found in any CGO_LDFLAGS -L path." +} + +status=0 + +if [[ "${BB_SKIP_SYSTEM_CHECK:-0}" == "1" ]]; then + echo "WARNING: skipping system checks because BB_SKIP_SYSTEM_CHECK=1." >&2 + exit 0 +fi + +if [[ $# -ne 0 ]]; then + usage + exit 2 +fi + +if ! command -v go >/dev/null 2>&1; then + fail "go is required but was not found in PATH." +else + cgo_enabled="$(go env CGO_ENABLED 2>/dev/null || true)" + [[ "$cgo_enabled" == "1" ]] || fail "CGO is disabled (go env CGO_ENABLED=${cgo_enabled:-})." +fi + +require_env CGO_CFLAGS "-I/path/to/rocksdb/include" +require_env CGO_LDFLAGS "-L/path/to/rocksdb -lrocksdb -lstdc++ -lm -lz -ldl -lbz2 -lsnappy -llz4 -lzstd" + +[[ "$status" -eq 0 ]] || exit "$status" + +read -r -a cflags <<< "${CGO_CFLAGS:-}" +read -r -a ldflags <<< "${CGO_LDFLAGS:-}" + +check_rocksdb_header +check_rocksdb_library + +if [[ "$status" -eq 0 ]]; then + echo "OK: Go/cgo/RocksDB dependencies are configured." >&2 +fi +exit "$status" diff --git a/contrib/tests/run-all-tests.sh b/contrib/tests/run-all-tests.sh new file mode 100755 index 0000000000..fdf244aaab --- /dev/null +++ b/contrib/tests/run-all-tests.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Run unit + integration suites sequentially. CI/CD use only — too slow for agent loops. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"$script_dir/run-unit-tests.sh" "$@" +"$script_dir/run-integration-tests.sh" "$@" diff --git a/contrib/tests/run-integration-tests.sh b/contrib/tests/run-integration-tests.sh new file mode 100755 index 0000000000..3c9b1440fe --- /dev/null +++ b/contrib/tests/run-integration-tests.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# requires `gh auth login` and read access to trezor/blockbook +# +# Pulls BB_* repository variables from trezor/blockbook via gh once, then runs +# the integration suite. Args are forwarded to `go test`; narrow with -run. +# +# By default the connectivity tests check only Blockbook reachability; the raw +# backend/node RPC checks need direct node access (only routable from CI) and +# are skipped locally. Pass --backend-connectivity (or export +# BB_TEST_BACKEND_CONNECTIVITY=1) to run them too. +# +# Examples: +# contrib/tests/run-integration-tests.sh +# contrib/tests/run-integration-tests.sh -run 'TestIntegration/ethereum=main/rpc' +# contrib/tests/run-integration-tests.sh -run 'TestIntegration/ethereum=main/rpc/GetBlock' -count=1 +# contrib/tests/run-integration-tests.sh --backend-connectivity -run 'TestIntegration/.*/connectivity' +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + +# Strip the local-only --backend-connectivity flag before forwarding to `go test`. +go_args=() +for arg in "$@"; do + case "$arg" in + --backend-connectivity) + export BB_TEST_BACKEND_CONNECTIVITY=1 + ;; + *) + go_args+=("$arg") + ;; + esac +done + +"$repo_root/contrib/system-check.sh" + +source "$script_dir/../gh-vars.sh" +bb_export_gh_vars + +export BB_BUILD_ENV="${BB_BUILD_ENV:-dev}" + +cd "$repo_root" +mapfile -t pkgs < <(go list github.com/trezor/blockbook/tests/...) +[[ ${#pkgs[@]} -gt 0 ]] || { echo "ERROR: 'go list' produced no packages." >&2; exit 1; } +exec go test -v -tags 'integration' "${pkgs[@]}" -run 'TestIntegration' -timeout 30m ${go_args[@]+"${go_args[@]}"} diff --git a/contrib/tests/run-openapi-tests.sh b/contrib/tests/run-openapi-tests.sh new file mode 100755 index 0000000000..26907ccf36 --- /dev/null +++ b/contrib/tests/run-openapi-tests.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Validate openapi.yaml, generate TypeScript API types, and run public API e2e +# checks against selected deployed Blockbook instances. +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" +openapi_dir="$repo_root/tests/openapi" + +if [[ ! -x "$openapi_dir/node_modules/.bin/redocly" ]]; then + echo "ERROR: OpenAPI test dependencies are not installed. Run: npm ci --prefix tests/openapi" >&2 + exit 1 +fi + +mkdir -p "$openapi_dir/.generated" +export NO_UPDATE_NOTIFIER="${NO_UPDATE_NOTIFIER:-1}" +export REDOCLY_TELEMETRY="${REDOCLY_TELEMETRY:-off}" +export REDOCLY_SUPPRESS_UPDATE_NOTICE="${REDOCLY_SUPPRESS_UPDATE_NOTICE:-true}" + +npm --prefix "$openapi_dir" run lint:spec +npm --prefix "$openapi_dir" run generate +# typecheck also runs tests/openapi/src/parity.ts, which fails the build when +# blockbook-api.ts (generated from Go) drifts from openapi.yaml. +npm --prefix "$openapi_dir" run typecheck + +export REPO_ROOT="$repo_root" +npm --prefix "$openapi_dir" run e2e diff --git a/contrib/tests/run-unit-tests.sh b/contrib/tests/run-unit-tests.sh new file mode 100755 index 0000000000..9878c5961e --- /dev/null +++ b/contrib/tests/run-unit-tests.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Run Blockbook unit tests directly (no Docker, no gh fetch). Args forwarded to `go test`. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" + +"$repo_root/contrib/system-check.sh" + +mapfile -t pkgs < <(go list ./... | grep -vE '^github\.com/trezor/blockbook/(contrib|tests)') +[[ ${#pkgs[@]} -gt 0 ]] || { echo "ERROR: 'go list' produced no packages." >&2; exit 1; } +exec go test -tags 'unittest' "${pkgs[@]}" "$@" 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..e169e2df44 --- /dev/null +++ b/db/address_hotness.go @@ -0,0 +1,206 @@ +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 lookup counts so we can decide when an address is hot. Counts + // accumulate across blocks so an address that recurs over several blocks (not + // only within one busy block) can become hot; the map is reset in BeginBlock + // once it grows past maxPendingHits to keep memory bounded. + hits map[addressHotnessKey]uint16 + // maxPendingHits bounds the hits map (set to the LRU size, the natural ceiling + // for promotion candidates). + maxPendingHits int + // 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), + maxPendingHits: lruSize, + 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 + } + // Hit counts accumulate across blocks so addresses looked up repeatedly over + // several blocks (not only within one busy block) can become hot — this lets + // the index help lower-throughput chains, not just very busy ones. Reset only + // when the candidate map grows past its bound; dropping pending counts merely + // delays a promotion and never affects correctness (lookups fall back to a + // linear scan when the index is not used). The LRU survives across blocks. + if len(h.hits) > h.maxPendingHits { + // Reinitialize rather than clear(): Go's clear() does not shrink a map's + // underlying bucket allocation, so a single oversized block would leave the + // allocation at its high-water mark. Pre-size to maxPendingHits, the + // steady-state ceiling, so the oversized buckets can be released. + h.hits = make(map[addressHotnessKey]uint16, h.maxPendingHits) + } + // Reset per-block stats counters (metrics report per-block deltas). + 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..3c638c0763 --- /dev/null +++ b/db/address_hotness_test.go @@ -0,0 +1,231 @@ +//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 accumulate hits across blocks", 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 counts to accumulate across blocks and promote") + } + }) + + t.Run("it should reset pending hits once the candidate map exceeds its bound", func(t *testing.T) { + // lruSize (and thus maxPendingHits) is 1 here. + hot := newAddressHotness(1, 1, 2) + if hot == nil { + t.Fatal("expected hotness tracker to be initialized") + } + a := makeHotKey(30) + b := makeHotKey(31) + hot.BeginBlock() + if hot.ShouldUseIndex(a, 1) { + t.Fatal("expected first hit on A to stay cold") + } + if hot.ShouldUseIndex(b, 1) { + t.Fatal("expected first hit on B to stay cold") + } + // hits now holds 2 pending entries > maxPendingHits (1), so BeginBlock clears it. + hot.BeginBlock() + if hot.ShouldUseIndex(a, 1) { + t.Fatal("expected A's pending hit to be cleared once the bound was exceeded") + } + }) + + 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 45f61a5cd7..5c958055c4 100644 --- a/db/bulkconnect.go +++ b/db/bulkconnect.go @@ -1,11 +1,13 @@ package db import ( + "fmt" "time" - "github.com/flier/gorocksdb" "github.com/golang/glog" - "github.com/syscoin/blockbook/bchain" + "github.com/linxGnu/grocksdb" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" ) // bulk connect @@ -15,8 +17,8 @@ import ( // 2) rocksdb seems to handle better fewer larger batches than continuous stream of smaller batches type bulkAddresses struct { - bi bchain.DbBlockInfo - addresses bchain.AddressesMap + bi BlockInfo + addresses addressesMap } // BulkConnect is used to connect blocks in bulk, faster but if interrupted inconsistent way @@ -25,12 +27,17 @@ type BulkConnect struct { chainType bchain.ChainType bulkAddresses []bulkAddresses bulkAddressesCount int - txAddressesMap map[string]*bchain.TxAddresses - balances map[string]*bchain.AddrBalance - addressContracts map[string]*AddrContracts - assets map[uint64]*bchain.Asset - txAssets bchain.TxAssetMap - height uint32 + ethBlockTxs []ethBlockTx + txAddressesMap map[string]*TxAddresses + blockFilters map[string][]byte + balances map[string]*AddrBalance + // SYSCOIN: cached SPT metadata/index rows during bulk sync. + assets map[uint64]*bchain.Asset + txAssets bchain.TxAssetMap + addressContracts map[string]*unpackedAddrContracts + height uint32 + bulkStats bulkConnectStats + bulkHotness bulkHotnessStats } const ( @@ -39,40 +46,77 @@ const ( partialStoreAddresses = maxBulkTxAddresses / 10 maxBulkBalances = 700000 partialStoreBalances = maxBulkBalances / 10 - maxBulkAddrContracts = 1200000 - partialStoreAddrContracts = maxBulkAddrContracts / 10 - maxBulkAssets = 100 + maxBulkAssets = 500000 // SYSCOIN partialStoreAssets = maxBulkAssets / 10 - maxBulkTxAssets = 500000 + maxBulkTxAssets = 500000 // SYSCOIN partialStoreTxAssets = maxBulkTxAssets / 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.assets = nil // SYSCOIN + b.txAssets = nil // SYSCOIN + 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{ d: d, chainType: d.chainParser.GetChainType(), - txAddressesMap: make(map[string]*bchain.TxAddresses), - balances: make(map[string]*bchain.AddrBalance), - addressContracts: make(map[string]*AddrContracts), - assets: make(map[uint64]*bchain.Asset), - txAssets: make(bchain.TxAssetMap), + txAddressesMap: make(map[string]*TxAddresses), + balances: make(map[string]*AddrBalance), + 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 + } + if supportsSyscoinAssets(d.chainParser) { + b.assets = make(map[uint64]*bchain.Asset) + b.txAssets = make(bchain.TxAssetMap) + } glog.Info("rocksdb: bulk connect init, db set to inconsistent state") return b, nil } -func (b *BulkConnect) storeTxAddresses(wb *gorocksdb.WriteBatch, all bool) (int, int, error) { - var txm map[string]*bchain.TxAddresses +func (b *BulkConnect) storeTxAddresses(wb *grocksdb.WriteBatch, all bool) (int, int, error) { + var txm map[string]*TxAddresses var sp int if all { txm = b.txAddressesMap - b.txAddressesMap = make(map[string]*bchain.TxAddresses) + b.txAddressesMap = make(map[string]*TxAddresses) } else { - txm = make(map[string]*bchain.TxAddresses) + txm = make(map[string]*TxAddresses) for k, a := range b.txAddressesMap { // store all completely spent transactions, they will not be modified again r := true @@ -108,14 +152,14 @@ func (b *BulkConnect) storeTxAddresses(wb *gorocksdb.WriteBatch, all bool) (int, func (b *BulkConnect) parallelStoreTxAddresses(c chan error, all bool) { defer close(c) start := time.Now() - wb := gorocksdb.NewWriteBatch() + wb := grocksdb.NewWriteBatch() defer wb.Destroy() count, sp, err := b.storeTxAddresses(wb, all) if err != nil { c <- err return } - if err := b.d.db.Write(b.d.wo, wb); err != nil { + if err := b.d.WriteBatch(wb); err != nil { c <- err return } @@ -123,14 +167,54 @@ func (b *BulkConnect) parallelStoreTxAddresses(c chan error, all bool) { c <- nil } -func (b *BulkConnect) storeAssets(wb *gorocksdb.WriteBatch, all bool) (int, error) { +func (b *BulkConnect) storeBalances(wb *grocksdb.WriteBatch, all bool) (int, error) { + var bal map[string]*AddrBalance + if all { + bal = b.balances + b.balances = make(map[string]*AddrBalance) + } else { + bal = make(map[string]*AddrBalance) + // store some random balances + for k, a := range b.balances { + bal[k] = a + delete(b.balances, k) + if len(bal) >= partialStoreBalances { + break + } + } + } + if err := b.d.storeBalances(wb, bal); err != nil { + return 0, err + } + return len(bal), nil +} + +func (b *BulkConnect) parallelStoreBalances(c chan error, all bool) { + defer close(c) + start := time.Now() + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + count, err := b.storeBalances(wb, all) + if err != nil { + c <- err + return + } + if err := b.d.WriteBatch(wb); err != nil { + c <- err + return + } + glog.Info("rocksdb: height ", b.height, ", stored ", count, " balances, ", len(b.balances), " remaining, done in ", time.Since(start)) + c <- nil +} + +// SYSCOIN: bulk store pending SPT asset metadata. +func (b *BulkConnect) storeAssets(wb *grocksdb.WriteBatch, all bool) (int, error) { var assets map[uint64]*bchain.Asset if all { assets = b.assets b.assets = make(map[uint64]*bchain.Asset) } else { assets = make(map[uint64]*bchain.Asset) - // store some random assets for k, a := range b.assets { assets[k] = a delete(b.assets, k) @@ -145,17 +229,18 @@ func (b *BulkConnect) storeAssets(wb *gorocksdb.WriteBatch, all bool) (int, erro return len(assets), nil } +// SYSCOIN func (b *BulkConnect) parallelStoreAssets(c chan error, all bool) { defer close(c) start := time.Now() - wb := gorocksdb.NewWriteBatch() + wb := grocksdb.NewWriteBatch() defer wb.Destroy() count, err := b.storeAssets(wb, all) if err != nil { c <- err return } - if err := b.d.db.Write(b.d.wo, wb); err != nil { + if err := b.d.WriteBatch(wb); err != nil { c <- err return } @@ -163,19 +248,18 @@ func (b *BulkConnect) parallelStoreAssets(c chan error, all bool) { c <- nil } - -func (b *BulkConnect) storeTxAssets(wb *gorocksdb.WriteBatch, all bool) (int, error) { +// SYSCOIN: bulk store pending per-asset transaction index rows. +func (b *BulkConnect) storeTxAssets(wb *grocksdb.WriteBatch, all bool) (int, error) { var assetsMap bchain.TxAssetMap if all { assetsMap = b.txAssets b.txAssets = make(bchain.TxAssetMap) } else { assetsMap = make(bchain.TxAssetMap) - // store some random asset txids for k, a := range b.txAssets { assetsMap[k] = a delete(b.txAssets, k) - if len(assetsMap) >= partialStoreAssets { + if len(assetsMap) >= partialStoreTxAssets { break } } @@ -186,65 +270,26 @@ func (b *BulkConnect) storeTxAssets(wb *gorocksdb.WriteBatch, all bool) (int, er return len(assetsMap), nil } +// SYSCOIN func (b *BulkConnect) parallelStoreTxAssets(c chan error, all bool) { defer close(c) start := time.Now() - wb := gorocksdb.NewWriteBatch() + wb := grocksdb.NewWriteBatch() defer wb.Destroy() count, err := b.storeTxAssets(wb, all) if err != nil { c <- err return } - if err := b.d.db.Write(b.d.wo, wb); err != nil { - c <- err - return - } - glog.Info("rocksdb: height ", b.height, ", stored ", count, " tx assets, ", len(b.assets), " remaining, done in ", time.Since(start)) - c <- nil -} - -func (b *BulkConnect) storeBalances(wb *gorocksdb.WriteBatch, all bool) (int, error) { - var bal map[string]*bchain.AddrBalance - if all { - bal = b.balances - b.balances = make(map[string]*bchain.AddrBalance) - } else { - bal = make(map[string]*bchain.AddrBalance) - // store some random balances - for k, a := range b.balances { - bal[k] = a - delete(b.balances, k) - if len(bal) >= partialStoreBalances { - break - } - } - } - if err := b.d.storeBalances(wb, bal); err != nil { - return 0, err - } - return len(bal), nil -} - -func (b *BulkConnect) parallelStoreBalances(c chan error, all bool) { - defer close(c) - start := time.Now() - wb := gorocksdb.NewWriteBatch() - defer wb.Destroy() - count, err := b.storeBalances(wb, all) - if err != nil { - c <- err - return - } - if err := b.d.db.Write(b.d.wo, wb); err != nil { + if err := b.d.WriteBatch(wb); err != nil { c <- err return } - glog.Info("rocksdb: height ", b.height, ", stored ", count, " balances, ", len(b.balances), " remaining, done in ", time.Since(start)) + glog.Info("rocksdb: height ", b.height, ", stored ", count, " tx assets, ", len(b.txAssets), " remaining, done in ", time.Since(start)) c <- nil } -func (b *BulkConnect) storeBulkAddresses(wb *gorocksdb.WriteBatch) error { +func (b *BulkConnect) storeBulkAddresses(wb *grocksdb.WriteBatch) error { for _, ba := range b.bulkAddresses { if err := b.d.storeAddresses(wb, ba.bi.Height, ba.addresses); err != nil { return err @@ -258,9 +303,94 @@ func (b *BulkConnect) storeBulkAddresses(wb *gorocksdb.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 { - addresses := make(bchain.AddressesMap) - if err := b.d.processAddressesBitcoinType(block, addresses, b.txAddressesMap, b.balances, b.assets, b.txAssets); err != nil { + b.addBitcoinStats(block) + addresses := make(addressesMap) + 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 + } + // SYSCOIN: pass SPT bulk caches when the parser supports asset indexing. + if err := b.d.processAddressesBitcoinType(block, addresses, b.txAddressesMap, b.balances, gf, b.assets, b.txAssets); err != nil { return err } var storeAddressesChan, storeBalancesChan, storeAssetsChan, storeTxAssetsChan chan error @@ -276,16 +406,18 @@ func (b *BulkConnect) connectBlockBitcoinType(block *bchain.Block, storeBlockTxs go b.parallelStoreBalances(storeBalancesChan, false) } if len(b.assets)+partialStoreAssets > maxBulkAssets { + // SYSCOIN storeAssetsChan = make(chan error) go b.parallelStoreAssets(storeAssetsChan, false) } if len(b.txAssets)+partialStoreTxAssets > maxBulkTxAssets { + // SYSCOIN storeTxAssetsChan = make(chan error) go b.parallelStoreTxAssets(storeTxAssetsChan, false) } } b.bulkAddresses = append(b.bulkAddresses, bulkAddresses{ - bi: bchain.DbBlockInfo{ + bi: BlockInfo{ Hash: block.Hash, Time: block.Time, Txs: uint32(len(block.Txs)), @@ -295,10 +427,13 @@ 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 := gorocksdb.NewWriteBatch() + wb := grocksdb.NewWriteBatch() defer wb.Destroy() bac := b.bulkAddressesCount if sa || b.bulkAddressesCount > maxBulkAddresses { @@ -311,11 +446,22 @@ func (b *BulkConnect) connectBlockBitcoinType(block *bchain.Block, storeBlockTxs return err } } - if err := b.d.db.Write(b.d.wo, wb); err != nil { + 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 { @@ -329,11 +475,13 @@ func (b *BulkConnect) connectBlockBitcoinType(block *bchain.Block, storeBlockTxs } } if storeAssetsChan != nil { + // SYSCOIN if err := <-storeAssetsChan; err != nil { return err } } if storeTxAssetsChan != nil { + // SYSCOIN if err := <-storeTxAssetsChan; err != nil { return err } @@ -341,13 +489,13 @@ func (b *BulkConnect) connectBlockBitcoinType(block *bchain.Block, storeBlockTxs return nil } -func (b *BulkConnect) storeAddressContracts(wb *gorocksdb.WriteBatch, all bool) (int, error) { - var ac map[string]*AddrContracts +func (b *BulkConnect) storeAddressContracts(wb *grocksdb.WriteBatch, all bool) (int, error) { + 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 @@ -357,7 +505,7 @@ func (b *BulkConnect) storeAddressContracts(wb *gorocksdb.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 @@ -366,14 +514,14 @@ func (b *BulkConnect) storeAddressContracts(wb *gorocksdb.WriteBatch, all bool) func (b *BulkConnect) parallelStoreAddressContracts(c chan error, all bool) { defer close(c) start := time.Now() - wb := gorocksdb.NewWriteBatch() + wb := grocksdb.NewWriteBatch() defer wb.Destroy() count, err := b.storeAddressContracts(wb, all) if err != nil { c <- err return } - if err := b.d.db.Write(b.d.wo, wb); err != nil { + if err := b.d.WriteBatch(wb); err != nil { c <- err return } @@ -382,11 +530,13 @@ func (b *BulkConnect) parallelStoreAddressContracts(c chan error, all bool) { } func (b *BulkConnect) connectBlockEthereumType(block *bchain.Block, storeBlockTxs bool) error { - addresses := make(bchain.AddressesMap) + addresses := make(addressesMap) blockTxs, err := b.d.processAddressesEthereumType(block, addresses, b.addressContracts) if err != nil { return err } + b.addEthereumStats(blockTxs) + b.ethBlockTxs = append(b.ethBlockTxs, blockTxs...) var storeAddrContracts chan error var sa bool if len(b.addressContracts) > maxBulkAddrContracts { @@ -395,7 +545,7 @@ func (b *BulkConnect) connectBlockEthereumType(block *bchain.Block, storeBlockTx go b.parallelStoreAddressContracts(storeAddrContracts, false) } b.bulkAddresses = append(b.bulkAddresses, bulkAddresses{ - bi: bchain.DbBlockInfo{ + bi: BlockInfo{ Hash: block.Hash, Time: block.Time, Txs: uint32(len(block.Txs)), @@ -408,24 +558,50 @@ func (b *BulkConnect) connectBlockEthereumType(block *bchain.Block, storeBlockTx // open WriteBatch only if going to write if sa || b.bulkAddressesCount > maxBulkAddresses || storeBlockTxs { start := time.Now() - wb := gorocksdb.NewWriteBatch() + wb := grocksdb.NewWriteBatch() defer wb.Destroy() bac := b.bulkAddressesCount if sa || b.bulkAddressesCount > maxBulkAddresses { - if err := b.storeBulkAddresses(wb); err != nil { + if err = b.storeBulkAddresses(wb); err != nil { return err } } + if err = b.d.storeInternalDataEthereumType(wb, b.ethBlockTxs); err != nil { + return err + } + b.ethBlockTxs = b.ethBlockTxs[:0] + if err = b.d.storeBlockSpecificDataEthereumType(wb, block); err != nil { + return err + } if storeBlockTxs { - if err := b.d.storeAndCleanupBlockTxsEthereumType(wb, block, blockTxs); err != nil { + if err = b.d.storeAndCleanupBlockTxsEthereumType(wb, block, blockTxs); err != nil { return err } } - if err := b.d.db.Write(b.d.wo, wb); err != nil { + 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() + } + } else { + // if there are blockSpecificData, store them + blockSpecificData, _ := block.CoinSpecificData.(*bchain.EthereumBlockSpecificData) + if blockSpecificData != nil { + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + if err = b.d.storeBlockSpecificDataEthereumType(wb, block); err != nil { + return err + } + if err := b.d.WriteBatch(wb); err != nil { + return err + } } } if storeAddrContracts != nil { @@ -448,9 +624,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, storeAssetsChan, storeTxAssetsChan chan error @@ -459,24 +644,40 @@ func (b *BulkConnect) Close() error { go b.parallelStoreTxAddresses(storeTxAddressesChan, true) storeBalancesChan = make(chan error) go b.parallelStoreBalances(storeBalancesChan, true) - storeAssetsChan = make(chan error) - go b.parallelStoreAssets(storeAssetsChan, true) - storeTxAssetsChan = make(chan error) - go b.parallelStoreTxAssets(storeTxAssetsChan, true) + if supportsSyscoinAssets(b.d.chainParser) { + // SYSCOIN + storeAssetsChan = make(chan error) + go b.parallelStoreAssets(storeAssetsChan, true) + storeTxAssetsChan = make(chan error) + go b.parallelStoreTxAssets(storeTxAssetsChan, true) + } } else if b.chainType == bchain.ChainEthereumType { storeAddressContractsChan = make(chan error) go b.parallelStoreAddressContracts(storeAddressContractsChan, true) } - wb := gorocksdb.NewWriteBatch() + 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.d.db.Write(b.d.wo, wb); err != nil { + if err := b.storeBulkBlockFilters(wb); err != nil { return err } - glog.Info("rocksdb: height ", b.height, ", stored ", bac, " addresses, done in ", time.Since(start)) + if err := b.d.WriteBatch(wb); err != nil { + return err + } + 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 @@ -487,31 +688,43 @@ func (b *BulkConnect) Close() error { return err } } - if storeAddressContractsChan != nil { - if err := <-storeAddressContractsChan; err != nil { - return err - } - } if storeAssetsChan != nil { + // SYSCOIN if err := <-storeAssetsChan; err != nil { return err } } if storeTxAssetsChan != nil { + // SYSCOIN if err := <-storeTxAssetsChan; err != nil { return err } } - var err error - b.d.is.BlockTimes, err = b.d.loadBlockTimes() - if err != nil { - return err + if storeAddressContractsChan != nil { + if err := <-storeAddressContractsChan; err != nil { + return err + } } - 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 4aa95bd8a4..ced33a2b94 100644 --- a/db/dboptions.go +++ b/db/dboptions.go @@ -2,31 +2,29 @@ package db // #include "rocksdb/c.h" import "C" - -import ( - "github.com/flier/gorocksdb" -) +import "flag" +import "github.com/linxGnu/grocksdb" /* - possible additional tuning, using options not accessible by gorocksdb + possible additional tuning, using options not accessible by grocksdb // #include "rocksdb/c.h" import "C" cNativeOpts := C.rocksdb_options_create() - opts := &gorocksdb.Options{} + opts := &grocksdb.Options{} cField := reflect.Indirect(reflect.ValueOf(opts)).FieldByName("c") cPtr := (**C.rocksdb_options_t)(unsafe.Pointer(cField.UnsafeAddr())) *cPtr = cNativeOpts cNativeBlockOpts := C.rocksdb_block_based_options_create() - blockOpts := &gorocksdb.BlockBasedTableOptions{} + blockOpts := &grocksdb.BlockBasedTableOptions{} cBlockField := reflect.Indirect(reflect.ValueOf(blockOpts)).FieldByName("c") cBlockPtr := (**C.rocksdb_block_based_table_options_t)(unsafe.Pointer(cBlockField.UnsafeAddr())) *cBlockPtr = cNativeBlockOpts // https://github.com/facebook/rocksdb/wiki/Partitioned-Index-Filters - blockOpts.SetIndexType(gorocksdb.KTwoLevelIndexSearchIndexType) + blockOpts.SetIndexType(grocksdb.KTwoLevelIndexSearchIndexType) C.rocksdb_block_based_options_set_partition_filters(cNativeBlockOpts, boolToChar(true)) C.rocksdb_block_based_options_set_metadata_block_size(cNativeBlockOpts, C.uint64_t(4096)) C.rocksdb_block_based_options_set_cache_index_and_filter_blocks_with_high_priority(cNativeBlockOpts, boolToChar(true)) @@ -41,16 +39,20 @@ func boolToChar(b bool) C.uchar { } */ -func createAndSetDBOptions(bloomBits int, c *gorocksdb.Cache, maxOpenFiles int) *gorocksdb.Options { - blockOpts := gorocksdb.NewDefaultBlockBasedTableOptions() +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 blockOpts.SetBlockCache(c) if bloomBits > 0 { - blockOpts.SetFilterPolicy(gorocksdb.NewBloomFilter(bloomBits)) + blockOpts.SetFilterPolicy(grocksdb.NewBloomFilter(float64(bloomBits))) } blockOpts.SetFormatVersion(4) - opts := gorocksdb.NewDefaultOptions() + opts := grocksdb.NewDefaultOptions() opts.SetBlockBasedTableFactory(blockOpts) opts.SetCreateIfMissing(true) opts.SetCreateIfMissingColumnFamilies(true) @@ -60,6 +62,11 @@ func createAndSetDBOptions(bloomBits int, c *gorocksdb.Cache, maxOpenFiles int) opts.SetWriteBufferSize(1 << 27) // 128MB opts.SetMaxBytesForLevelBase(1 << 27) // 128MB opts.SetMaxOpenFiles(maxOpenFiles) - opts.SetCompression(gorocksdb.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 new file mode 100644 index 0000000000..8cf6c9fa9d --- /dev/null +++ b/db/fiat.go @@ -0,0 +1,353 @@ +package db + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "math" + "time" + + vlq "github.com/bsm/go-vlq" + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/linxGnu/grocksdb" + "github.com/trezor/blockbook/common" +) + +// FiatRatesTimeFormat is a format string for storing FiatRates timestamps in rocksdb +const FiatRatesTimeFormat = "20060102150405" // YYYYMMDDhhmmss + +const historicalFiatBootstrapStateKey = "HistoricalFiatBootstrapComplete" +const historicalFiatBootstrapAttemptsKey = "HistoricalFiatBootstrapAttempts" + +func packTimestamp(t *time.Time) []byte { + return []byte(t.UTC().Format(FiatRatesTimeFormat)) +} + +func packFloat32(buf []byte, n float32) int { + binary.BigEndian.PutUint32(buf, math.Float32bits(n)) + return 4 +} + +func unpackFloat32(buf []byte) (float32, int) { + return math.Float32frombits(binary.BigEndian.Uint32(buf)), 4 +} + +func packCurrencyRatesTicker(ticker *common.CurrencyRatesTicker) []byte { + buf := make([]byte, 0, 32) + varBuf := make([]byte, vlq.MaxLen64) + l := packVaruint(uint(len(ticker.Rates)), varBuf) + buf = append(buf, varBuf[:l]...) + for c, v := range ticker.Rates { + buf = append(buf, packString(c)...) + l = packFloat32(varBuf, v) + buf = append(buf, varBuf[:l]...) + } + l = packVaruint(uint(len(ticker.TokenRates)), varBuf) + buf = append(buf, varBuf[:l]...) + for c, v := range ticker.TokenRates { + buf = append(buf, packString(c)...) + l = packFloat32(varBuf, v) + buf = append(buf, varBuf[:l]...) + } + return buf +} + +func unpackCurrencyRatesTicker(buf []byte) (*common.CurrencyRatesTicker, error) { + var ( + ticker common.CurrencyRatesTicker + s string + l int + len uint + v float32 + ) + len, l = unpackVaruint(buf) + buf = buf[l:] + if len > 0 { + ticker.Rates = make(map[string]float32, len) + for i := 0; i < int(len); i++ { + s, l = unpackString(buf) + buf = buf[l:] + v, l = unpackFloat32(buf) + buf = buf[l:] + ticker.Rates[s] = v + } + } + len, l = unpackVaruint(buf) + buf = buf[l:] + if len > 0 { + ticker.TokenRates = make(map[string]float32, len) + for i := 0; i < int(len); i++ { + s, l = unpackString(buf) + buf = buf[l:] + v, l = unpackFloat32(buf) + buf = buf[l:] + ticker.TokenRates[s] = v + } + } + return &ticker, nil +} + +// FiatRatesStoreTicker stores ticker data at the specified time +func (d *RocksDB) FiatRatesStoreTicker(wb *grocksdb.WriteBatch, ticker *common.CurrencyRatesTicker) error { + if len(ticker.Rates) == 0 { + return errors.New("Error storing ticker: empty rates") + } + wb.PutCF(d.cfh[cfFiatRates], packTimestamp(&ticker.Timestamp), packCurrencyRatesTicker(ticker)) + return nil +} + +func getTickerFromIterator(it *grocksdb.Iterator, vsCurrency string, token string) (*common.CurrencyRatesTicker, error) { + timeObj, err := time.Parse(FiatRatesTimeFormat, string(it.Key().Data())) + if err != nil { + return nil, err + } + ticker, err := unpackCurrencyRatesTicker(it.Value().Data()) + if err != nil { + return nil, err + } + if !common.IsSuitableTicker(ticker, vsCurrency, token) { + return nil, nil + } + ticker.Timestamp = timeObj.UTC() + return ticker, nil +} + +// FiatRatesGetTicker gets FiatRates ticker at the specified timestamp if it exist +func (d *RocksDB) FiatRatesGetTicker(tickerTime *time.Time) (*common.CurrencyRatesTicker, error) { + tickerTimeFormatted := tickerTime.UTC().Format(FiatRatesTimeFormat) + val, err := d.db.GetCF(d.ro, d.cfh[cfFiatRates], []byte(tickerTimeFormatted)) + if err != nil { + return nil, err + } + defer val.Free() + data := val.Data() + if len(data) == 0 { + return nil, nil + } + ticker, err := unpackCurrencyRatesTicker(data) + if err != nil { + return nil, err + } + ticker.Timestamp = tickerTime.UTC() + return ticker, nil +} + +// 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) { + tickerTimeFormatted := tickerTime.UTC().Format(FiatRatesTimeFormat) + it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates]) + defer it.Close() + + for it.Seek([]byte(tickerTimeFormatted)); it.Valid(); it.Next() { + ticker, err := getTickerFromIterator(it, vsCurrency, token) + if err != nil { + glog.Error("FiatRatesFindTicker error: ", err) + return nil, err + } + if ticker != nil { + return ticker, nil + } + } + 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 +} + +// FiatRatesGetTickerTimestamps returns, in ascending order, the unix-second timestamps of all +// stored fiat-rate tickers at or after `from`. It reads keys only (no value decoding), so it +// stays cheap even over long history -- used by the startup reconciliation to detect which +// whole days are missing without loading every (potentially large) record. +func (d *RocksDB) FiatRatesGetTickerTimestamps(from time.Time) ([]int64, error) { + fromKey := []byte(from.UTC().Format(FiatRatesTimeFormat)) + it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates]) + defer it.Close() + + var timestamps []int64 + for it.Seek(fromKey); it.Valid(); it.Next() { + t, err := time.Parse(FiatRatesTimeFormat, string(it.Key().Data())) + if err != nil { + // defensively skip any key that is not a valid timestamp + continue + } + timestamps = append(timestamps, t.Unix()) + } + return timestamps, 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]) + defer it.Close() + + for it.SeekToLast(); it.Valid(); it.Prev() { + ticker, err := getTickerFromIterator(it, vsCurrency, token) + if err != nil { + glog.Error("FiatRatesFindLastTicker error: ", err) + return nil, err + } + if ticker != nil { + 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 new file mode 100644 index 0000000000..3d743d36db --- /dev/null +++ b/db/fiat_test.go @@ -0,0 +1,329 @@ +//go:build unittest + +package db + +import ( + "reflect" + "testing" + "time" + + "github.com/linxGnu/grocksdb" + "github.com/trezor/blockbook/common" +) + +func TestRocksTickers(t *testing.T) { + d := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + // Test storing & finding tickers + pastKey, _ := time.Parse(FiatRatesTimeFormat, "20190627000000") + futureKey, _ := time.Parse(FiatRatesTimeFormat, "20190630000000") + + ts1, _ := time.Parse(FiatRatesTimeFormat, "20190628000000") + ticker1 := &common.CurrencyRatesTicker{ + Timestamp: ts1, + Rates: map[string]float32{ + "usd": 20000, + "eur": 18000, + }, + TokenRates: map[string]float32{ + "0x6B175474E89094C44Da98b954EedeAC495271d0F": 17.2, + }, + } + + ts2, _ := time.Parse(FiatRatesTimeFormat, "20190629000000") + ticker2 := &common.CurrencyRatesTicker{ + Timestamp: ts2, + Rates: map[string]float32{ + "usd": 30000, + }, + TokenRates: map[string]float32{ + "0x82dF128257A7d7556262E1AB7F1f639d9775B85E": 13.1, + "0x6B175474E89094C44Da98b954EedeAC495271d0F": 17.5, + }, + } + + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + err := d.FiatRatesStoreTicker(wb, ticker1) + if err != nil { + t.Errorf("Error storing ticker! %v", err) + } + err = d.FiatRatesStoreTicker(wb, ticker2) + if err != nil { + t.Errorf("Error storing ticker! %v", err) + } + err = d.WriteBatch(wb) + if err != nil { + t.Errorf("Error storing ticker! %v", err) + } + + // test FiatRatesGetTicker with ticker that should be in DB + t1, err := d.FiatRatesGetTicker(&ts1) + if err != nil || t1 == nil { + t.Fatalf("FiatRatesGetTicker t1 %v", err) + } + if !reflect.DeepEqual(t1, ticker1) { + t.Fatalf("FiatRatesGetTicker(t1) = %v, want %v", *t1, *ticker1) + } + // test FiatRatesGetTicker with ticker that is not in DB + t2, err := d.FiatRatesGetTicker(&pastKey) + if err != nil || t2 != nil { + t.Fatalf("FiatRatesGetTicker t2 %v, %v", err, t2) + } + + ticker, err := d.FiatRatesFindTicker(&pastKey, "", "") // should find the closest key (ticker1) + 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) + } + + ticker, err = d.FiatRatesFindLastTicker("", "") // should find the last key (ticker2) + if err != nil { + t.Errorf("TestRocksTickers err: %+v", err) + } else if ticker == nil { + t.Errorf("Ticker not found") + } else if ticker.Timestamp.Format(FiatRatesTimeFormat) != ticker2.Timestamp.Format(FiatRatesTimeFormat) { + t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker1.Timestamp, ticker.Timestamp) + } + + ticker, err = d.FiatRatesFindTicker(&futureKey, "", "") // should not find anything + if err != nil { + t.Errorf("TestRocksTickers err: %+v", err) + } else if ticker != nil { + t.Errorf("Ticker found, but the timestamp is older than the last ticker entry.") + } + + ticker, err = d.FiatRatesFindTicker(&pastKey, "", "0x6B175474E89094C44Da98b954EedeAC495271d0F") // should find the closest key (ticker1) + 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) + } + + ticker, err = d.FiatRatesFindTicker(&pastKey, "", "0x82dF128257A7d7556262E1AB7F1f639d9775B85E") // should find the last key (ticker2) + if err != nil { + t.Errorf("TestRocksTickers err: %+v", err) + } else if ticker == nil { + t.Errorf("Ticker not found") + } else if ticker.Timestamp.Format(FiatRatesTimeFormat) != ticker2.Timestamp.Format(FiatRatesTimeFormat) { + t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker2.Timestamp, ticker.Timestamp) + } + + ticker, err = d.FiatRatesFindLastTicker("eur", "") // should find the closest key (ticker1) + 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) + } + + ticker, err = d.FiatRatesFindLastTicker("usd", "") // should find the last key (ticker2) + if err != nil { + t.Errorf("TestRocksTickers err: %+v", err) + } else if ticker == nil { + t.Errorf("Ticker not found") + } else if ticker.Timestamp.Format(FiatRatesTimeFormat) != ticker2.Timestamp.Format(FiatRatesTimeFormat) { + t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker2.Timestamp, ticker.Timestamp) + } + + ticker, err = d.FiatRatesFindLastTicker("aud", "") // should not find any key + if err != nil { + t.Errorf("TestRocksTickers err: %+v", err) + } else if ticker != nil { + t.Errorf("Ticker %v found unexpectedly for aud vsCurrency", 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) + } + + 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.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]) + } + + // 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) { + type args struct { + } + tests := []struct { + name string + data common.CurrencyRatesTicker + }{ + { + name: "empty", + data: common.CurrencyRatesTicker{}, + }, + { + name: "rates", + data: common.CurrencyRatesTicker{ + Rates: map[string]float32{ + "usd": 2129.2341123, + "eur": 1332.51234, + }, + }, + }, + { + name: "rates&tokenrates", + data: common.CurrencyRatesTicker{ + Rates: map[string]float32{ + "usd": 322129.987654321, + "eur": 291332.12345678, + }, + TokenRates: map[string]float32{ + "0x82dF128257A7d7556262E1AB7F1f639d9775B85E": 0.4092341123, + "0x6B175474E89094C44Da98b954EedeAC495271d0F": 12.32323232323232, + "0xdAC17F958D2ee523a2206206994597C13D831ec7": 1332421341235.51234, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + packed := packCurrencyRatesTicker(&tt.data) + got, err := unpackCurrencyRatesTicker(packed) + if err != nil { + t.Errorf("unpackCurrencyRatesTicker() error = %v", err) + return + } + if !reflect.DeepEqual(got, &tt.data) { + t.Errorf("unpackCurrencyRatesTicker() = %v, want %v", *got, tt.data) + } + }) + } +} diff --git a/db/rocksdb.go b/db/rocksdb.go index 7dd9493140..8111ff9a03 100644 --- a/db/rocksdb.go +++ b/db/rocksdb.go @@ -2,64 +2,41 @@ package db import ( "bytes" + "encoding/binary" "encoding/hex" - "encoding/json" "fmt" "math/big" "os" "path/filepath" "sort" "strconv" + "sync" + "sync/atomic" "time" "unsafe" vlq "github.com/bsm/go-vlq" - "github.com/flier/gorocksdb" "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/common" + "github.com/linxGnu/grocksdb" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" ) -const dbVersion = 5 +const dbVersion = 7 + +const packedHeightBytes = 4 +const maxAddrDescLen = 1024 // iterator creates snapshot, which takes lots of resources // when doing huge scan, it is better to close it and reopen from time to time to free the resources const refreshIterator = 5000000 -// FiatRatesTimeFormat is a format string for storing FiatRates timestamps in rocksdb -const FiatRatesTimeFormat = "20060102150405" // YYYYMMDDhhmmss - -// CurrencyRatesTicker contains coin ticker data fetched from API -type CurrencyRatesTicker struct { - Timestamp *time.Time // return as unix timestamp in API - Rates map[string]float64 -} - -// ResultTickerAsString contains formatted CurrencyRatesTicker data -type ResultTickerAsString struct { - Timestamp int64 `json:"ts,omitempty"` - Rates map[string]float64 `json:"rates"` - Error string `json:"error,omitempty"` -} - -// ResultTickersAsString contains a formatted CurrencyRatesTicker list -type ResultTickersAsString struct { - Tickers []ResultTickerAsString `json:"tickers"` -} - -// ResultTickerListAsString contains formatted data about available currency tickers -type ResultTickerListAsString struct { - Timestamp int64 `json:"ts,omitempty"` - Tickers []string `json:"available_currencies"` - Error string `json:"error,omitempty"` -} - // RepairRocksDB calls RocksDb db repair function func RepairRocksDB(name string) error { glog.Infof("rocksdb: repair") - opts := gorocksdb.NewDefaultOptions() - return gorocksdb.RepairDb(name, opts) + opts := grocksdb.NewDefaultOptions() + return grocksdb.RepairDb(name, opts) } type connectBlockStats struct { @@ -69,22 +46,60 @@ type connectBlockStats struct { balancesMiss int } +// AddressBalanceDetail specifies what data are returned by GetAddressBalance +type AddressBalanceDetail int + +const ( + // AddressBalanceDetailNoUTXO returns address balance without utxos + AddressBalanceDetailNoUTXO = 0 + // AddressBalanceDetailUTXO returns address balance with utxos + AddressBalanceDetailUTXO = 1 + // addressBalanceDetailUTXOIndexed returns address balance with utxos and index for updates, used only internally + 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 *gorocksdb.DB - wo *gorocksdb.WriteOptions - ro *gorocksdb.ReadOptions - cfh []*gorocksdb.ColumnFamilyHandle - chainParser bchain.BlockChainParser - is *common.InternalState - metrics *common.Metrics - cache *gorocksdb.Cache - maxOpenFiles int - cbs connectBlockStats - // SYSCOIN - chain bchain.BlockChain + 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 + // SYSCOIN: used only for NEVM metadata fallback on asset-cache misses. + chain bchain.BlockChain } const ( @@ -97,12 +112,26 @@ const ( // BitcoinType cfAddressBalance cfTxAddresses - // SyscoinType + cfBlockFilter + // SYSCOIN: SPT metadata and per-asset tx history. cfAssets cfTxAssets - // EthereumType - cfAddressContracts = cfAddressBalance + __break__ + + // EthereumType + cfAddressContracts = iota - __break__ + cfAddressBalance - 1 + cfInternalData + cfContracts + cfFunctionSignatures + cfBlockInternalDataErrors + + // 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 @@ -110,23 +139,35 @@ var cfNames []string var cfBaseNames = []string{"default", "height", "addresses", "blockTxs", "transactions", "fiatRates"} // type specific columns -var cfNamesBitcoinType = []string{"addressBalance", "txAddresses", "assets", "txAssets"} -var cfNamesEthereumType = []string{"addressContracts"} +var cfNamesBitcoinType = []string{"addressBalance", "txAddresses", "blockFilter"} +var cfNamesSyscoinType = []string{"assets", "txAssets"} // SYSCOIN +var cfNamesEthereumType = []string{"addressContracts", "internalData", "contracts", "functionSignatures", "blockInternalDataErrors", "addressAliases", "ercProtocols"} + +// SYSCOIN: parser capability flag for enabling SPT column families. +type syscoinAssetsSupport interface { + SupportsSyscoinAssets() bool +} -func openDB(path string, c *gorocksdb.Cache, openFiles int) (*gorocksdb.DB, []*gorocksdb.ColumnFamilyHandle, error) { +// SYSCOIN +func supportsSyscoinAssets(parser bchain.BlockChainParser) bool { + p, ok := parser.(syscoinAssetsSupport) + return ok && p.SupportsSyscoinAssets() +} + +func openDB(path string, c *grocksdb.Cache, openFiles int) (*grocksdb.DB, []*grocksdb.ColumnFamilyHandle, error) { // opts with bloom filter opts := createAndSetDBOptions(10, c, openFiles) // opts for addresses without bloom filter // from documentation: if most of your queries are executed using iterators, you shouldn't set bloom filter optsAddresses := createAndSetDBOptions(0, c, openFiles) // default, height, addresses, blockTxids, transactions - cfOptions := []*gorocksdb.Options{opts, opts, optsAddresses, opts, opts, opts} + cfOptions := []*grocksdb.Options{opts, opts, optsAddresses, opts, opts, opts} // append type specific options count := len(cfNames) - len(cfOptions) for i := 0; i < count; i++ { cfOptions = append(cfOptions, opts) } - db, cfh, err := gorocksdb.OpenDbColumnFamilies(opts, path, cfNames, cfOptions) + db, cfh, err := grocksdb.OpenDbColumnFamilies(opts, path, cfNames, cfOptions) if err != nil { return nil, nil, err } @@ -135,30 +176,93 @@ func openDB(path string, c *gorocksdb.Cache, openFiles int) (*gorocksdb.DB, []*g // 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, chain bchain.BlockChain) (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...) chainType := parser.GetChainType() if chainType == bchain.ChainBitcoinType { cfNames = append(cfNames, cfNamesBitcoinType...) + if supportsSyscoinAssets(parser) { + cfNames = append(cfNames, cfNamesSyscoinType...) + } } else if chainType == bchain.ChainEthereumType { cfNames = append(cfNames, cfNamesEthereumType...) + extendedIndex = false } else { return nil, errors.New("Unknown chain type") } - c := gorocksdb.NewLRUCache(uint64(cacheSize)) + c := grocksdb.NewLRUCache(uint64(cacheSize)) db, cfh, err := openDB(path, c, maxOpenFiles) if err != nil { return nil, err } - wo := gorocksdb.NewDefaultWriteOptions() - ro := gorocksdb.NewDefaultReadOptions() - return &RocksDB{path, db, wo, ro, cfh, parser, nil, metrics, c, maxOpenFiles, connectBlockStats{}, chain}, nil + wo := grocksdb.NewDefaultWriteOptions() + ro := grocksdb.NewDefaultReadOptions() + 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 +} + +// SetBlockChain wires the live chain into RocksDB for optional chain-specific +// lookup fallbacks. +// +// SYSCOIN: asset metadata can be lazily enriched from NEVM when it is missing +// from the local asset column family. +func (d *RocksDB) SetBlockChain(chain bchain.BlockChain) { + d.chain = chain } func (d *RocksDB) closeDB() error { + d.setBlockTimesWG.Wait() for _, h := range d.cfh { h.Destroy() } @@ -167,107 +271,13 @@ func (d *RocksDB) closeDB() error { return 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(ticker *CurrencyRatesTicker) error { - if len(ticker.Rates) == 0 { - return errors.New("Error storing ticker: empty rates") - } else if ticker.Timestamp == nil { - return errors.New("Error storing ticker: empty timestamp") - } - ratesMarshalled, err := json.Marshal(ticker.Rates) - if err != nil { - glog.Error("Error marshalling ticker rates: ", err) - return err - } - timeFormatted := ticker.Timestamp.UTC().Format(FiatRatesTimeFormat) - err = d.db.PutCF(d.wo, d.cfh[cfFiatRates], []byte(timeFormatted), ratesMarshalled) - if err != nil { - glog.Error("Error storing ticker: ", err) - return err - } - return nil -} - -// FiatRatesFindTicker gets FiatRates data closest to the specified timestamp -func (d *RocksDB) FiatRatesFindTicker(tickerTime *time.Time) (*CurrencyRatesTicker, error) { - ticker := &CurrencyRatesTicker{} - tickerTimeFormatted := tickerTime.UTC().Format(FiatRatesTimeFormat) - it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates]) - defer it.Close() - - for it.Seek([]byte(tickerTimeFormatted)); it.Valid(); it.Next() { - timeObj, err := time.Parse(FiatRatesTimeFormat, string(it.Key().Data())) - if err != nil { - glog.Error("FiatRatesFindTicker time parse error: ", err) - return nil, err - } - timeObj = timeObj.UTC() - ticker.Timestamp = &timeObj - err = json.Unmarshal(it.Value().Data(), &ticker.Rates) - if err != nil { - glog.Error("FiatRatesFindTicker error unpacking rates: ", err) - return nil, err - } - break - } - if err := it.Err(); err != nil { - glog.Error("FiatRatesFindTicker Iterator error: ", err) - return nil, err - } - if !it.Valid() { - return nil, nil // ticker not found - } - return ticker, nil -} - -// FiatRatesFindLastTicker gets the last FiatRates record -func (d *RocksDB) FiatRatesFindLastTicker() (*CurrencyRatesTicker, error) { - ticker := &CurrencyRatesTicker{} - it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates]) - defer it.Close() - - for it.SeekToLast(); it.Valid(); it.Next() { - timeObj, err := time.Parse(FiatRatesTimeFormat, string(it.Key().Data())) - if err != nil { - glog.Error("FiatRatesFindTicker time parse error: ", err) - return nil, err - } - timeObj = timeObj.UTC() - ticker.Timestamp = &timeObj - err = json.Unmarshal(it.Value().Data(), &ticker.Rates) - if err != nil { - glog.Error("FiatRatesFindTicker error unpacking rates: ", err) - return nil, err - } - break - } - if err := it.Err(); err != nil { - glog.Error("FiatRatesFindLastTicker Iterator error: ", err) - return ticker, err - } - if !it.Valid() { - return nil, nil // ticker not found - } - return ticker, nil -} - // 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 @@ -307,6 +317,15 @@ func atoUint64(s string) uint64 { return uint64(i) } +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 @@ -345,7 +364,7 @@ func (e *StopIteration) Error() string { // GetTransactionsCallback is called by GetTransactions/GetAddrDescTransactions for each found tx // indexes contain array of indexes (input negative, output positive) in tx where is given address -type GetTransactionsCallback func(txid string, height uint32, assetGuids []uint64, indexes []int32) error +type GetTransactionsCallback func(txid string, height uint32, indexes []int32) error // GetTransactions finds all input/output transactions for address // Transaction are passed to callback function. @@ -357,20 +376,17 @@ func (d *RocksDB) GetTransactions(address string, lower uint32, higher uint32, f if err != nil { return err } - return d.GetAddrDescTransactions(addrDesc, lower, higher, bchain.AllMask, fn) + return d.GetAddrDescTransactions(addrDesc, lower, higher, fn) } // GetAddrDescTransactions finds all input/output transactions for address descriptor // Transaction are passed to callback function in the order from newest block to the oldest -func (d *RocksDB) GetAddrDescTransactions(addrDesc bchain.AddressDescriptor, lower uint32, higher uint32, assetsBitMask bchain.AssetsMask, fn GetTransactionsCallback) (err error) { - assetsBitMaskUint := uint32(assetsBitMask) +func (d *RocksDB) GetAddrDescTransactions(addrDesc bchain.AddressDescriptor, lower uint32, higher uint32, fn GetTransactionsCallback) (err error) { txidUnpackedLen := d.chainParser.PackedTxidLen() - txIndexUnpackedLen := d.chainParser.PackedTxIndexLen() addrDescLen := len(addrDesc) - startKey := d.chainParser.PackAddressKey(addrDesc, higher) - stopKey := d.chainParser.PackAddressKey(addrDesc, lower) + startKey := packAddressKey(addrDesc, higher) + stopKey := packAddressKey(addrDesc, lower) indexes := make([]int32, 0, 16) - assetGuids := make([]uint64, 0, 8) it := d.db.NewIteratorCF(d.ro, d.cfh[cfAddresses]) defer it.Close() for it.Seek(startKey); it.Valid(); it.Next() { @@ -378,7 +394,7 @@ func (d *RocksDB) GetAddrDescTransactions(addrDesc bchain.AddressDescriptor, low if bytes.Compare(key, stopKey) > 0 { break } - if len(key) != addrDescLen+bchain.PackedHeightBytes { + if len(key) != addrDescLen+packedHeightBytes { if glog.V(2) { glog.Warningf("rocksdb: addrDesc %s - mixed with %s", addrDesc, hex.EncodeToString(key)) } @@ -388,37 +404,37 @@ func (d *RocksDB) GetAddrDescTransactions(addrDesc bchain.AddressDescriptor, low if glog.V(2) { glog.Infof("rocksdb: addresses %s: %s", hex.EncodeToString(key), hex.EncodeToString(val)) } - _, height, err := d.chainParser.UnpackAddressKey(key) + _, height, err := unpackAddressKey(key) if err != nil { return err } - for len(val) > txIndexUnpackedLen { - mask, l := d.chainParser.UnpackTxIndexType(val) - maskUint := uint32(mask) - tx, err := d.chainParser.UnpackTxid(val[l:l+txidUnpackedLen]) + for len(val) > txidUnpackedLen { + tx, err := d.chainParser.UnpackTxid(val[:txidUnpackedLen]) if err != nil { return err } indexes = indexes[:0] - val = val[l+txidUnpackedLen:] - err = d.chainParser.UnpackTxIndexes(&indexes, &val) - if err != nil { - glog.Warningf("rocksdb: addresses (tx index) contain incorrect data %s: %s", hex.EncodeToString(key), hex.EncodeToString(val)) - break + val = val[txidUnpackedLen:] + for { + index, l := unpackVarint32(val) + indexes = append(indexes, index>>1) + val = val[l:] + if index&1 == 1 { + break + } else if len(val) == 0 { + glog.Warningf("rocksdb: addresses contain incorrect data %s: %s", hex.EncodeToString(key), hex.EncodeToString(val)) + break + } } - assetGuids = assetGuids[:0] - d.chainParser.UnpackTxIndexAssets(&assetGuids, &val) - if assetsBitMask == bchain.AllMask || mask == bchain.AllMask || (assetsBitMaskUint & maskUint) == maskUint { - if err := fn(tx, height, assetGuids, indexes); err != nil { - if _, ok := err.(*StopIteration); ok { - return nil - } - return err + if err := fn(tx, height, indexes); err != nil { + if _, ok := err.(*StopIteration); ok { + return nil } + return err } } if len(val) != 0 { - glog.Warningf("rocksdb: addresses (bytes unread) contain incorrect data %s: %s", hex.EncodeToString(key), hex.EncodeToString(val)) + glog.Warningf("rocksdb: addresses contain incorrect data %s: %s", hex.EncodeToString(key), hex.EncodeToString(val)) } } return nil @@ -429,11 +445,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 { - wb := gorocksdb.NewWriteBatch() + 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) } @@ -443,15 +473,33 @@ func (d *RocksDB) ConnectBlock(block *bchain.Block) error { if err := d.writeHeightFromBlock(wb, block, opInsert); err != nil { return err } - addresses := make(bchain.AddressesMap) + addresses := make(addressesMap) if chainType == bchain.ChainBitcoinType { - assets := make(map[uint64]*bchain.Asset) - txAssets := make(bchain.TxAssetMap, 0) - txAddressesMap := make(map[string]*bchain.TxAddresses) - balances := make(map[string]*bchain.AddrBalance) - if err := d.processAddressesBitcoinType(block, addresses, txAddressesMap, balances, assets, txAssets); err != nil { + txAddressesMap := make(map[string]*TxAddresses) + balances := make(map[string]*AddrBalance) + var assets map[uint64]*bchain.Asset + var txAssets bchain.TxAssetMap + if supportsSyscoinAssets(d.chainParser) { + assets = make(map[uint64]*bchain.Asset) + txAssets = make(bchain.TxAssetMap) + } + 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, assets, txAssets); 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 } @@ -461,19 +509,42 @@ func (d *RocksDB) ConnectBlock(block *bchain.Block) error { if err := d.storeAndCleanupBlockTxs(wb, block); err != nil { return err } - if err := d.storeAssets(wb, assets); err != nil { - return err + if supportsSyscoinAssets(d.chainParser) { + if err := d.storeAssets(wb, assets); err != nil { + return err + } + if err := d.storeTxAssets(wb, txAssets); err != nil { + return err + } } - if err := d.storeTxAssets(wb, txAssets); 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 { + return err + } + if err = d.storeBlockSpecificDataEthereumType(wb, block); err != nil { return err } if err := d.storeAndCleanupBlockTxsEthereumType(wb, block, blockTxs); err != nil { @@ -485,13 +556,222 @@ func (d *RocksDB) ConnectBlock(block *bchain.Block) error { if err := d.storeAddresses(wb, block.Height, addresses); err != nil { return err } - if err := d.db.Write(d.wo, wb); err != nil { + if err := d.WriteBatch(wb); err != nil { return err } - 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 } +// Addresses index + +type txIndexes struct { + btxID []byte + indexes []int32 +} + +// addressesMap is a map of addresses in a block +// each address contains a slice of transactions with indexes where the address appears +// slice is used instead of map so that order is defined and also search in case of few items +type addressesMap map[string][]txIndexes + +type outpoint struct { + btxID []byte + index int32 +} + +// TxInput holds input data of the transaction in TxAddresses +type TxInput struct { + AddrDesc bchain.AddressDescriptor + ValueSat big.Int + // SYSCOIN: SPT metadata on the spent output. + AssetInfo *bchain.AssetInfo + // extended index properties + Txid string + Vout uint32 +} + +// Addresses converts AddressDescriptor of the input to array of strings +func (ti *TxInput) Addresses(p bchain.BlockChainParser) ([]string, bool, error) { + return p.GetAddressesFromAddrDesc(ti.AddrDesc) +} + +// TxOutput holds output data of the transaction in TxAddresses +type TxOutput struct { + AddrDesc bchain.AddressDescriptor + Spent bool + ValueSat big.Int + // SYSCOIN: SPT metadata on this UTXO. + AssetInfo *bchain.AssetInfo + // extended index properties + SpentTxid string + SpentIndex uint32 + SpentHeight uint32 +} + +// Addresses converts AddressDescriptor of the output to array of strings +func (to *TxOutput) Addresses(p bchain.BlockChainParser) ([]string, bool, error) { + return p.GetAddressesFromAddrDesc(to.AddrDesc) +} + +// TxAddresses stores transaction inputs and outputs with amounts +type TxAddresses struct { + Height uint32 + Inputs []TxInput + Outputs []TxOutput + // SYSCOIN: tx version and optional SPT memo. + Version int32 + Memo []byte + // extended index properties + VSize uint32 +} + +// Utxo holds information about unspent transaction output +type Utxo struct { + BtxID []byte + Vout int32 + Height uint32 + ValueSat big.Int + AssetInfo *bchain.AssetInfo // SYSCOIN +} + +// AddrBalance stores number of transactions and balances of an address +type AddrBalance struct { + Txs uint32 + SentSat big.Int + BalanceSat big.Int + Utxos []Utxo + AssetBalances map[uint64]*bchain.AssetBalance // SYSCOIN + utxosMap map[string]int +} + +// ReceivedSat computes received amount from total balance and sent amount +func (ab *AddrBalance) ReceivedSat() *big.Int { + var r big.Int + r.Add(&ab.BalanceSat, &ab.SentSat) + return &r +} + +// addUtxo +func (ab *AddrBalance) addUtxo(u *Utxo) { + ab.Utxos = append(ab.Utxos, *u) + ab.manageUtxoMap(u) +} + +func (ab *AddrBalance) manageUtxoMap(u *Utxo) { + l := len(ab.Utxos) + if l >= 16 { + if len(ab.utxosMap) == 0 { + ab.utxosMap = make(map[string]int, 32) + for i := 0; i < l; i++ { + s := string(ab.Utxos[i].BtxID) + if _, e := ab.utxosMap[s]; !e { + ab.utxosMap[s] = i + } + } + } else { + s := string(u.BtxID) + if _, e := ab.utxosMap[s]; !e { + ab.utxosMap[s] = l - 1 + } + } + } +} + +// on disconnect, the added utxos must be inserted in the right position so that utxosMap index works +func (ab *AddrBalance) addUtxoInDisconnect(u *Utxo) { + insert := -1 + if len(ab.utxosMap) > 0 { + if i, e := ab.utxosMap[string(u.BtxID)]; e { + insert = i + } + } else { + for i := range ab.Utxos { + utxo := &ab.Utxos[i] + if *(*int)(unsafe.Pointer(&utxo.BtxID[0])) == *(*int)(unsafe.Pointer(&u.BtxID[0])) && bytes.Equal(utxo.BtxID, u.BtxID) { + insert = i + break + } + } + } + if insert > -1 { + // check if it is necessary to insert the utxo into the array + for i := insert; i < len(ab.Utxos); i++ { + utxo := &ab.Utxos[i] + // either the vout is greater than the inserted vout or it is a different tx + if utxo.Vout > u.Vout || *(*int)(unsafe.Pointer(&utxo.BtxID[0])) != *(*int)(unsafe.Pointer(&u.BtxID[0])) || !bytes.Equal(utxo.BtxID, u.BtxID) { + // found the right place, insert the utxo + ab.Utxos = append(ab.Utxos, *u) + copy(ab.Utxos[i+1:], ab.Utxos[i:]) + ab.Utxos[i] = *u + // reset utxosMap after insert, the index will have to be rebuilt if needed + ab.utxosMap = nil + return + } + } + } + ab.Utxos = append(ab.Utxos, *u) + ab.manageUtxoMap(u) +} + +// markUtxoAsSpent finds outpoint btxID:vout in utxos and marks it as spent +// for small number of utxos the linear search is done, for larger number there is a hashmap index +// it is much faster than removing the utxo from the slice as it would cause in memory reallocations +func (ab *AddrBalance) markUtxoAsSpent(btxID []byte, vout int32) { + if len(ab.utxosMap) == 0 { + for i := range ab.Utxos { + utxo := &ab.Utxos[i] + if utxo.Vout == vout && *(*int)(unsafe.Pointer(&utxo.BtxID[0])) == *(*int)(unsafe.Pointer(&btxID[0])) && bytes.Equal(utxo.BtxID, btxID) { + // mark utxo as spent by setting vout=-1 + utxo.Vout = -1 + return + } + } + } else { + if i, e := ab.utxosMap[string(btxID)]; e { + l := len(ab.Utxos) + for ; i < l; i++ { + utxo := &ab.Utxos[i] + if utxo.Vout == vout { + if bytes.Equal(utxo.BtxID, btxID) { + // mark utxo as spent by setting vout=-1 + utxo.Vout = -1 + return + } + break + } + } + } + } + glog.Errorf("Utxo %s:%d not found, utxosMap size %d", hex.EncodeToString(btxID), vout, len(ab.utxosMap)) +} + +type blockTxs struct { + btxID []byte + inputs []outpoint +} + func (d *RocksDB) resetValueSatToZero(valueSat *big.Int, addrDesc bchain.AddressDescriptor, logText string) { ad, _, err := d.chainParser.GetAddressesFromAddrDesc(addrDesc) if err != nil { @@ -509,9 +789,10 @@ func (d *RocksDB) GetAndResetConnectBlockStats() string { return s } -func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses bchain.AddressesMap, txAddressesMap map[string]*bchain.TxAddresses, balances map[string]*bchain.AddrBalance, assets map[uint64]*bchain.Asset, txAssets bchain.TxAssetMap) error { +// SYSCOIN: assets/txAssets are non-nil only for Syscoin SPT indexing. +func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses addressesMap, txAddressesMap map[string]*TxAddresses, balances map[string]*AddrBalance, gf *bchain.GolombFilter, assets map[uint64]*bchain.Asset, txAssets bchain.TxAssetMap) error { blockTxIDs := make([][]byte, len(block.Txs)) - blockTxAddresses := make([]*bchain.TxAddresses, len(block.Txs)) + blockTxAddresses := make([]*TxAddresses, len(block.Txs)) blockTxAssetAddresses := make(bchain.TxAssetAddressMap) // first process all outputs so that inputs can refer to txs in this block for txi := range block.Txs { @@ -521,17 +802,23 @@ func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses bch return err } blockTxIDs[txi] = btxID - ta := bchain.TxAddresses{Version: tx.Version, Height: block.Height, Memo: tx.Memo} - ta.Outputs = make([]bchain.TxOutput, len(tx.Vout)) - ta.Inputs = make([]bchain.TxInput, len(tx.Vin)) + ta := TxAddresses{Height: block.Height, Version: tx.Version, Memo: tx.Memo} + 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 - maxAddrDescLen := d.chainParser.GetMaxAddrLength() - assetsMask := d.chainParser.GetAssetsMaskFromVersion(tx.Version) - 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) + tao.AssetInfo = output.AssetInfo + 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) @@ -543,20 +830,20 @@ func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses bch } continue } - tao.AddrDesc = addrDesc - if output.AssetInfo != nil { - tao.AssetInfo = &bchain.AssetInfo{AssetGuid: output.AssetInfo.AssetGuid, ValueSat: new(big.Int).Set(output.AssetInfo.ValueSat)} + if gf != nil { + gf.AddAddrDesc(addrDesc, tx) } + tao.AddrDesc = addrDesc if d.chainParser.IsAddrDescIndexable(addrDesc) { strAddrDesc := string(addrDesc) balance, e := balances[strAddrDesc] if !e { - balance, err = d.GetAddrDescBalance(addrDesc, bchain.AddressBalanceDetailUTXOIndexed) + balance, err = d.GetAddrDescBalance(addrDesc, addressBalanceDetailUTXOIndexed) if err != nil { return err } if balance == nil { - balance = &bchain.AddrBalance{} + balance = &AddrBalance{} } balances[strAddrDesc] = balance d.cbs.balancesMiss++ @@ -564,31 +851,30 @@ func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses bch d.cbs.balancesHit++ } balance.BalanceSat.Add(&balance.BalanceSat, &output.ValueSat) - balance.AddUtxo(&bchain.Utxo{ - BtxID: btxID, - Vout: int32(i), - Height: block.Height, - ValueSat: output.ValueSat, - AssetInfo: tao.AssetInfo, - }) - counted := addToAddressesMap(addresses, strAddrDesc, btxID, int32(i), assetsMask, tao.AssetInfo) - if !counted { - balance.Txs++ - } - if tao.AssetInfo != nil { + if output.AssetInfo != nil { if balance.AssetBalances == nil { - balance.AssetBalances = map[uint64]*bchain.AssetBalance{} + balance.AssetBalances = make(map[uint64]*bchain.AssetBalance) } - balanceAsset, ok := balance.AssetBalances[tao.AssetInfo.AssetGuid] - if !ok { - balanceAsset = &bchain.AssetBalance{Transfers: 0, BalanceSat: big.NewInt(0), SentSat: big.NewInt(0)} - balance.AssetBalances[tao.AssetInfo.AssetGuid] = balanceAsset + balanceAsset := balance.AssetBalances[output.AssetInfo.AssetGuid] + if balanceAsset == nil { + balanceAsset = &bchain.AssetBalance{BalanceSat: new(big.Int), SentSat: new(big.Int)} + balance.AssetBalances[output.AssetInfo.AssetGuid] = balanceAsset } - err = d.ConnectAllocationOutput(&addrDesc, block.Height, balanceAsset, tx.Version, btxID, tao.AssetInfo, blockTxAssetAddresses, assets, txAssets, tx.Memo) - if err != nil { + if err := d.ConnectAllocationOutput(&addrDesc, block.Height, balanceAsset, tx.Version, btxID, output.AssetInfo, blockTxAssetAddresses, assets, txAssets, tx.Memo); err != nil { return err } } + balance.addUtxo(&Utxo{ + BtxID: btxID, + Vout: int32(i), + Height: block.Height, + ValueSat: output.ValueSat, + AssetInfo: output.AssetInfo, + }) + counted := addToAddressesMap(addresses, strAddrDesc, btxID, int32(i)) + if !counted { + balance.Txs++ + } } } } @@ -597,9 +883,10 @@ func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses bch tx := &block.Txs[txi] spendingTxid := blockTxIDs[txi] ta := blockTxAddresses[txi] + ta.Inputs = make([]TxInput, len(tx.Vin)) logged := false - assetsMask := d.chainParser.GetAssetsMaskFromVersion(tx.Version) - 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 { @@ -623,25 +910,32 @@ func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses bch } txAddressesMap[stxID] = ita d.cbs.txAddressesMiss++ + } else { + d.cbs.txAddressesHit++ } - if len(ita.Outputs) <= int(input.Vout) { glog.Warningf("rocksdb: height %d, tx %v, input tx %v vout %v is out of bounds of stored tx", block.Height, tx.Txid, input.Txid, input.Vout) continue } - spentOutput := &ita.Outputs[int(input.Vout)] + spentOutput := &ita.Outputs[int(input.Vout)] 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 - - if spentOutput.AssetInfo != nil { - tai.AssetInfo = &bchain.AssetInfo{AssetGuid: spentOutput.AssetInfo.AssetGuid, ValueSat: new(big.Int).Set(spentOutput.AssetInfo.ValueSat)} - } + tai.AssetInfo = spentOutput.AssetInfo // 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) @@ -653,42 +947,41 @@ func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses bch strAddrDesc := string(spentOutput.AddrDesc) balance, e := balances[strAddrDesc] if !e { - balance, err = d.GetAddrDescBalance(spentOutput.AddrDesc, bchain.AddressBalanceDetailUTXOIndexed) + balance, err = d.GetAddrDescBalance(spentOutput.AddrDesc, addressBalanceDetailUTXOIndexed) if err != nil { return err } if balance == nil { - balance = &bchain.AddrBalance{} + balance = &AddrBalance{} } balances[strAddrDesc] = balance d.cbs.balancesMiss++ } else { d.cbs.balancesHit++ } - counted := addToAddressesMap(addresses, strAddrDesc, spendingTxid, ^int32(i), assetsMask, spentOutput.AssetInfo) + counted := addToAddressesMap(addresses, strAddrDesc, spendingTxid, ^int32(i)) if !counted { balance.Txs++ } balance.BalanceSat.Sub(&balance.BalanceSat, &spentOutput.ValueSat) - balance.MarkUtxoAsSpent(btxID, int32(input.Vout)) - if balance.BalanceSat.Sign() < 0 { - d.resetValueSatToZero(&balance.BalanceSat, spentOutput.AddrDesc, "balance") - } - balance.SentSat.Add(&balance.SentSat, &spentOutput.ValueSat) if spentOutput.AssetInfo != nil { if balance.AssetBalances == nil { - balance.AssetBalances = map[uint64]*bchain.AssetBalance{} + balance.AssetBalances = make(map[uint64]*bchain.AssetBalance) } - balanceAsset, ok := balance.AssetBalances[spentOutput.AssetInfo.AssetGuid] - if !ok { - balanceAsset = &bchain.AssetBalance{Transfers: 0, BalanceSat: big.NewInt(0), SentSat: big.NewInt(0)} + balanceAsset := balance.AssetBalances[spentOutput.AssetInfo.AssetGuid] + if balanceAsset == nil { + balanceAsset = &bchain.AssetBalance{BalanceSat: new(big.Int), SentSat: new(big.Int)} balance.AssetBalances[spentOutput.AssetInfo.AssetGuid] = balanceAsset } - err := d.ConnectAllocationInput(&spentOutput.AddrDesc, block.Height, tx.Version, balanceAsset, spendingTxid, spentOutput.AssetInfo, blockTxAssetAddresses, assets, txAssets) - if err != nil { + if err := d.ConnectAllocationInput(&spentOutput.AddrDesc, block.Height, tx.Version, balanceAsset, spendingTxid, spentOutput.AssetInfo, blockTxAssetAddresses, assets, txAssets); err != nil { return err } } + balance.markUtxoAsSpent(btxID, int32(input.Vout)) + if balance.BalanceSat.Sign() < 0 { + d.resetValueSatToZero(&balance.BalanceSat, spentOutput.AddrDesc, "balance") + } + balance.SentSat.Add(&balance.SentSat, &spentOutput.ValueSat) } } } @@ -698,126 +991,110 @@ func (d *RocksDB) processAddressesBitcoinType(block *bchain.Block, addresses bch // addToAddressesMap maintains mapping between addresses and transactions in one block // the method assumes that outputs in the block are processed before the inputs // the return value is true if the tx was processed before, to not to count the tx multiple times -func addToAddressesMap(addresses bchain.AddressesMap, strAddrDesc string, btxID []byte, index int32, assetsMask bchain.AssetsMask, assetInfo *bchain.AssetInfo) bool { +func addToAddressesMap(addresses addressesMap, strAddrDesc string, btxID []byte, index int32) bool { // check that the address was already processed in this block // if not found, it has certainly not been counted at, found := addresses[strAddrDesc] if found { // if the tx is already in the slice, append the index to the array of indexes for i, t := range at { - if bytes.Equal(btxID, t.BtxID) { - at[i].Indexes = append(t.Indexes, index) - // append asset if set - if assetInfo != nil { - foundAsset := false - // only append if not existing already - for _, assetGuidFound := range t.Assets { - if assetInfo.AssetGuid == assetGuidFound { - foundAsset = true - break - } - } - if !foundAsset { - at[i].Assets = append(t.Assets, assetInfo.AssetGuid) - } - } + if bytes.Equal(btxID, t.btxID) { + at[i].indexes = append(t.indexes, index) return true } } - } - txIndex := &bchain.TxIndexes{ - Type: assetsMask, - BtxID: btxID, - Indexes: []int32{index}, - } - // create asset array if assetInfo is set - if assetInfo != nil { - txIndex.Assets = []uint64{assetInfo.AssetGuid} } - addresses[strAddrDesc] = append(at, *txIndex) + addresses[strAddrDesc] = append(at, txIndexes{ + btxID: btxID, + indexes: []int32{index}, + }) return false } -func (d *RocksDB) storeAddresses(wb *gorocksdb.WriteBatch, height uint32, addresses bchain.AddressesMap) error { +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) - key := d.chainParser.PackAddressKey(ba, height) - val := d.chainParser.PackTxIndexes(txi) + key := packAddressKey(ba, height) + val := d.packTxIndexes(txi) wb.PutCF(d.cfh[cfAddresses], key, val) } return nil } - -func (d *RocksDB) storeTxAddresses(wb *gorocksdb.WriteBatch, am map[string]*bchain.TxAddresses) error { - varBuf := make([]byte, d.chainParser.MaxPackedBigintBytes()) +func (d *RocksDB) storeTxAddresses(wb *grocksdb.WriteBatch, am map[string]*TxAddresses) error { + varBuf := make([]byte, maxPackedBigintBytes) buf := make([]byte, 1024) for txID, ta := range am { - buf = d.chainParser.PackTxAddresses(ta, buf, varBuf) + buf = d.packTxAddresses(ta, buf, varBuf) wb.PutCF(d.cfh[cfTxAddresses], []byte(txID), buf) } return nil } -func (d *RocksDB) storeBalances(wb *gorocksdb.WriteBatch, abm map[string]*bchain.AddrBalance) error { +func (d *RocksDB) storeBalances(wb *grocksdb.WriteBatch, abm map[string]*AddrBalance) error { // allocate buffer initial buffer buf := make([]byte, 1024) - varBuf := make([]byte, d.chainParser.MaxPackedBigintBytes()) + varBuf := make([]byte, maxPackedBigintBytes) for addrDesc, ab := range abm { // balance with 0 transactions is removed from db - happens on disconnect if ab == nil || ab.Txs <= 0 { - glog.Warning("txs <= 0") wb.DeleteCF(d.cfh[cfAddressBalance], bchain.AddressDescriptor(addrDesc)) } else { - // asset transfers with 0 transactions are removed from db - happens on disconnect - for key, value := range ab.AssetBalances { - if value.Transfers <= 0 { - // ensure transactions for asset are also 0, asset activate will have transfers as 0 but transactions as 1 - dBAsset, err := d.GetAsset(key, nil) - if err != nil { - return errors.New(fmt.Sprintf("storeBalances could not read asset %d, err %v" , key, err)) - } - if(dBAsset.Transactions <= 0) { - delete(ab.AssetBalances, key) - } - } - } - buf = d.chainParser.PackAddrBalance(ab, buf, varBuf) + buf = d.packAddrBalance(ab, buf, varBuf) wb.PutCF(d.cfh[cfAddressBalance], bchain.AddressDescriptor(addrDesc), buf) } } return nil } -func (d *RocksDB) cleanupBlockTxs(wb *gorocksdb.WriteBatch, block *bchain.Block) error { +func (d *RocksDB) cleanupBlockTxs(wb *grocksdb.WriteBatch, block *bchain.Block) error { keep := d.chainParser.KeepBlockAddresses() // cleanup old block address if block.Height > uint32(keep) { for rh := block.Height - uint32(keep); rh > 0; rh-- { - key := d.chainParser.PackUint(rh) + key := packUint(rh) val, err := d.db.GetCF(d.ro, d.cfh[cfBlockTxs], key) if err != nil { return err } // 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 } -func (d *RocksDB) storeAndCleanupBlockTxs(wb *gorocksdb.WriteBatch, block *bchain.Block) error { +func (d *RocksDB) storeAndCleanupBlockTxs(wb *grocksdb.WriteBatch, block *bchain.Block) error { pl := d.chainParser.PackedTxidLen() buf := make([]byte, 0, pl*len(block.Txs)) varBuf := make([]byte, vlq.MaxLen64) zeroTx := make([]byte, pl) for i := range block.Txs { tx := &block.Txs[i] - o := make([]bchain.DbOutpoint, len(tx.Vin)) + o := make([]outpoint, len(tx.Vin)) for v := range tx.Vin { vin := &tx.Vin[v] btxID, err := d.chainParser.PackTxid(vin.Txid) @@ -829,32 +1106,32 @@ func (d *RocksDB) storeAndCleanupBlockTxs(wb *gorocksdb.WriteBatch, block *bchai return err } } - o[v].BtxID = btxID - o[v].Index = int32(vin.Vout) + o[v].btxID = btxID + o[v].index = int32(vin.Vout) } btxID, err := d.chainParser.PackTxid(tx.Txid) if err != nil { return err } buf = append(buf, btxID...) - l := d.chainParser.PackVaruint(uint(len(o)), varBuf) + l := packVaruint(uint(len(o)), varBuf) buf = append(buf, varBuf[:l]...) - buf = append(buf, d.chainParser.PackOutpoints(o)...) + buf = append(buf, d.packOutpoints(o)...) } - key := d.chainParser.PackUint(block.Height) + key := packUint(block.Height) wb.PutCF(d.cfh[cfBlockTxs], key, buf) return d.cleanupBlockTxs(wb, block) } -func (d *RocksDB) getBlockTxs(height uint32) ([]bchain.BlockTxs, error) { +func (d *RocksDB) getBlockTxs(height uint32) ([]blockTxs, error) { pl := d.chainParser.PackedTxidLen() - val, err := d.db.GetCF(d.ro, d.cfh[cfBlockTxs], d.chainParser.PackUint(height)) + val, err := d.db.GetCF(d.ro, d.cfh[cfBlockTxs], packUint(height)) if err != nil { return nil, err } defer val.Free() buf := val.Data() - bt := make([]bchain.BlockTxs, 0, 8) + bt := make([]blockTxs, 0, 8) for i := 0; i < len(buf); { if len(buf)-i < pl { glog.Error("rocksdb: Inconsistent data in blockTxs ", hex.EncodeToString(buf)) @@ -862,14 +1139,14 @@ func (d *RocksDB) getBlockTxs(height uint32) ([]bchain.BlockTxs, error) { } txid := append([]byte(nil), buf[i:i+pl]...) i += pl - o, ol, err := d.chainParser.UnpackNOutpoints(buf[i:]) + o, ol, err := d.unpackNOutpoints(buf[i:]) if err != nil { glog.Error("rocksdb: Inconsistent data in blockTxs ", hex.EncodeToString(buf)) return nil, errors.New("Inconsistent data in blockTxs") } - bt = append(bt, bchain.BlockTxs{ - BtxID: txid, - Inputs: o, + bt = append(bt, blockTxs{ + btxID: txid, + inputs: o, }) i += ol } @@ -877,69 +1154,535 @@ func (d *RocksDB) getBlockTxs(height uint32) ([]bchain.BlockTxs, error) { } // GetAddrDescBalance returns AddrBalance for given addrDesc -func (d *RocksDB) GetAddrDescBalance(addrDesc bchain.AddressDescriptor, detail bchain.AddressBalanceDetail) (*bchain.AddrBalance, error) { +func (d *RocksDB) GetAddrDescBalance(addrDesc bchain.AddressDescriptor, detail AddressBalanceDetail) (*AddrBalance, error) { val, err := d.db.GetCF(d.ro, d.cfh[cfAddressBalance], addrDesc) if err != nil { return nil, err } defer val.Free() buf := val.Data() - // 4 is minimum length of addrBalance - 1 byte txs, 1 byte sent, 1 byte balance, 1 byte assetinfo flag - if len(buf) < 4 { + // 3 is minimum length of addrBalance - 1 byte txs, 1 byte sent, 1 byte balance + if len(buf) < 3 { return nil, nil } - return d.chainParser.UnpackAddrBalance(buf, d.chainParser.PackedTxidLen(), detail) + return d.unpackAddrBalance(buf, d.chainParser.PackedTxidLen(), detail) } // GetAddressBalance returns address balance for an address or nil if address not found -func (d *RocksDB) GetAddressBalance(address string, detail bchain.AddressBalanceDetail) (*bchain.AddrBalance, error) { +func (d *RocksDB) GetAddressBalance(address string, detail AddressBalanceDetail) (*AddrBalance, error) { addrDesc, err := d.chainParser.GetAddrDescFromAddress(address) if err != nil { return nil, err } - return d.GetAddrDescBalance(addrDesc, detail) + return d.GetAddrDescBalance(addrDesc, detail) +} + +func (d *RocksDB) getTxAddresses(btxID []byte) (*TxAddresses, error) { + val, err := d.db.GetCF(d.ro, d.cfh[cfTxAddresses], btxID) + if err != nil { + return nil, err + } + defer val.Free() + buf := val.Data() + // 2 is minimum length of addrBalance - 1 byte height, 1 byte inputs len, 1 byte outputs len + if len(buf) < 3 { + return nil, nil + } + return d.unpackTxAddresses(buf) +} + +// GetTxAddresses returns TxAddresses for given txid or nil if not found +func (d *RocksDB) GetTxAddresses(txid string) (*TxAddresses, error) { + btxID, err := d.chainParser.PackTxid(txid) + if err != nil { + return nil, err + } + return d.getTxAddresses(btxID) +} + +// AddrDescForOutpoint is a function that returns address descriptor, value, +// and optional asset metadata for given outpoint or nil if outpoint not found. +func (d *RocksDB) AddrDescForOutpoint(outpoint bchain.Outpoint) (bchain.AddressDescriptor, *big.Int, *bchain.AssetInfo) { + ta, err := d.GetTxAddresses(outpoint.Txid) + if err != nil || ta == nil { + return nil, nil, nil + } + if outpoint.Vout < 0 { + vin := ^outpoint.Vout + if len(ta.Inputs) <= int(vin) { + return nil, nil, nil + } + return ta.Inputs[vin].AddrDesc, &ta.Inputs[vin].ValueSat, ta.Inputs[vin].AssetInfo + } + if len(ta.Outputs) <= int(outpoint.Vout) { + return nil, nil, nil + } + return ta.Outputs[outpoint.Vout].AddrDesc, &ta.Outputs[outpoint.Vout].ValueSat, ta.Outputs[outpoint.Vout].AssetInfo +} + +func (d *RocksDB) packTxAddresses(ta *TxAddresses, buf []byte, varBuf []byte) []byte { + buf = buf[:0] + if supportsSyscoinAssets(d.chainParser) { + l := packVaruint(uint(ta.Version), varBuf) + buf = append(buf, varBuf[:l]...) + } + 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 = 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 = d.appendTxOutput(&ta.Outputs[i], buf, varBuf) + } + if supportsSyscoinAssets(d.chainParser) { + buf = append(buf, packVarBytes(ta.Memo)...) + } + return buf +} + +func (d *RocksDB) appendTxInput(txi *TxInput, buf []byte, varBuf []byte) []byte { + la := len(txi.AddrDesc) + 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]...) + } + if supportsSyscoinAssets(d.chainParser) { + buf = appendAssetInfo(txi.AssetInfo, buf, varBuf) + } + return buf +} + +func (d *RocksDB) appendTxOutput(txo *TxOutput, buf []byte, varBuf []byte) []byte { + la := len(txo.AddrDesc) + if txo.Spent { + la = ^la + } + l := packVarint(la, varBuf) + buf = append(buf, varBuf[:l]...) + 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]...) + } + if supportsSyscoinAssets(d.chainParser) { + buf = appendAssetInfo(txo.AssetInfo, buf, varBuf) + } + return buf +} + +func (d *RocksDB) unpackAddrBalance(buf []byte, txidUnpackedLen int, detail AddressBalanceDetail) (*AddrBalance, error) { + txs, l := unpackVaruint(buf) + sentSat, sl := unpackBigint(buf[l:]) + balanceSat, bl := unpackBigint(buf[l+sl:]) + l = l + sl + bl + ab := &AddrBalance{ + Txs: uint32(txs), + SentSat: sentSat, + BalanceSat: balanceSat, + } + if supportsSyscoinAssets(d.chainParser) { + numAssetBalances, ll := unpackVaruint(buf[l:]) + l += ll + if numAssetBalances > 0 { + ab.AssetBalances = make(map[uint64]*bchain.AssetBalance, numAssetBalances) + for i := uint(0); i < numAssetBalances; i++ { + assetGuid, ll := unpackVaruint64(buf[l:]) + l += ll + balanceValue, ll := unpackBigint(buf[l:]) + l += ll + sentValue, ll := unpackBigint(buf[l:]) + l += ll + transfers, ll := unpackVaruint(buf[l:]) + l += ll + ab.AssetBalances[assetGuid] = &bchain.AssetBalance{Transfers: uint32(transfers), SentSat: &sentValue, BalanceSat: &balanceValue} + } + } + } + if detail != AddressBalanceDetailNoUTXO { + // estimate the size of utxos to avoid reallocation + ab.Utxos = make([]Utxo, 0, len(buf[l:])/txidUnpackedLen+3) + // ab.utxosMap = make(map[string]int, cap(ab.Utxos)) + for len(buf[l:]) >= txidUnpackedLen+3 { + btxID := append([]byte(nil), buf[l:l+txidUnpackedLen]...) + l += txidUnpackedLen + vout, ll := unpackVaruint(buf[l:]) + l += ll + height, ll := unpackVaruint(buf[l:]) + l += ll + valueSat, ll := unpackBigint(buf[l:]) + l += ll + u := Utxo{ + BtxID: btxID, + Vout: int32(vout), + Height: uint32(height), + ValueSat: valueSat, + } + if supportsSyscoinAssets(d.chainParser) { + var assetLen int + u.AssetInfo, assetLen = unpackAssetInfo(buf[l:]) + l += assetLen + } + if detail == AddressBalanceDetailUTXO { + ab.Utxos = append(ab.Utxos, u) + } else { + ab.addUtxo(&u) + } + } + } + return ab, nil +} + +func unpackAddrBalance(buf []byte, txidUnpackedLen int, detail AddressBalanceDetail) (*AddrBalance, error) { + return (&RocksDB{}).unpackAddrBalance(buf, txidUnpackedLen, detail) +} + +func packAddrBalance(ab *AddrBalance, buf, varBuf []byte) []byte { + buf = buf[:0] + l := packVaruint(uint(ab.Txs), varBuf) + buf = append(buf, varBuf[:l]...) + l = packBigint(&ab.SentSat, varBuf) + buf = append(buf, varBuf[:l]...) + l = packBigint(&ab.BalanceSat, varBuf) + buf = append(buf, varBuf[:l]...) + // Asset balances are encoded only by d.packAddrBalance for Syscoin. + for _, utxo := range ab.Utxos { + // 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) + buf = append(buf, varBuf[:l]...) + l = packVaruint(uint(utxo.Height), varBuf) + buf = append(buf, varBuf[:l]...) + l = packBigint(&utxo.ValueSat, varBuf) + buf = append(buf, varBuf[:l]...) + } + } + return buf +} + +func (d *RocksDB) packAddrBalance(ab *AddrBalance, buf, varBuf []byte) []byte { + if !supportsSyscoinAssets(d.chainParser) { + return packAddrBalance(ab, buf, varBuf) + } + buf = buf[:0] + l := packVaruint(uint(ab.Txs), varBuf) + buf = append(buf, varBuf[:l]...) + l = packBigint(&ab.SentSat, varBuf) + buf = append(buf, varBuf[:l]...) + l = packBigint(&ab.BalanceSat, varBuf) + buf = append(buf, varBuf[:l]...) + l = packVaruint(uint(len(ab.AssetBalances)), varBuf) + buf = append(buf, varBuf[:l]...) + for guid, balance := range ab.AssetBalances { + l = packVaruint64(guid, varBuf) + buf = append(buf, varBuf[:l]...) + l = packBigint(balance.BalanceSat, varBuf) + buf = append(buf, varBuf[:l]...) + l = packBigint(balance.SentSat, varBuf) + buf = append(buf, varBuf[:l]...) + l = packVaruint(uint(balance.Transfers), varBuf) + buf = append(buf, varBuf[:l]...) + } + for _, utxo := range ab.Utxos { + if utxo.Vout >= 0 { + buf = append(buf, utxo.BtxID...) + l = packVaruint(uint(utxo.Vout), varBuf) + buf = append(buf, varBuf[:l]...) + l = packVaruint(uint(utxo.Height), varBuf) + buf = append(buf, varBuf[:l]...) + l = packBigint(&utxo.ValueSat, varBuf) + buf = append(buf, varBuf[:l]...) + buf = appendAssetInfo(utxo.AssetInfo, buf, varBuf) + } + } + return buf +} + +func (d *RocksDB) unpackTxAddresses(buf []byte) (*TxAddresses, error) { + ta := TxAddresses{} + var l int + if supportsSyscoinAssets(d.chainParser) { + version, ll := unpackVaruint(buf) + ta.Version = int32(version) + l += ll + } + height, ll := unpackVaruint(buf[l:]) + ta.Height = uint32(height) + l += ll + 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 += 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 += d.unpackTxOutput(&ta.Outputs[i], buf[l:]) + } + if supportsSyscoinAssets(d.chainParser) { + ta.Memo, _ = unpackVarBytes(buf[l:]) + } + return &ta, nil +} + +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 + } + if supportsSyscoinAssets(d.chainParser) { + var assetLen int + ti.AssetInfo, assetLen = unpackAssetInfo(buf[al:]) + al += assetLen + } + 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:]) + al += uint(l) + if supportsSyscoinAssets(d.chainParser) { + var assetLen int + ti.AssetInfo, assetLen = unpackAssetInfo(buf[al:]) + al += uint(assetLen) + } + return int(al) + } +} + +func (d *RocksDB) unpackTxOutput(to *TxOutput, buf []byte) int { + al, l := unpackVarint(buf) + if al < 0 { + to.Spent = true + al = ^al + } + to.AddrDesc = append([]byte(nil), buf[l:l+al]...) + al += l + to.ValueSat, l = unpackBigint(buf[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 + } + if supportsSyscoinAssets(d.chainParser) { + var assetLen int + to.AssetInfo, assetLen = unpackAssetInfo(buf[al:]) + al += assetLen + } + return al +} + +func (d *RocksDB) packTxIndexes(txi []txIndexes) []byte { + buf := make([]byte, 0, 32) + bvout := make([]byte, vlq.MaxLen32) + // store the txs in reverse order for ordering from newest to oldest + for j := len(txi) - 1; j >= 0; j-- { + t := &txi[j] + buf = append(buf, []byte(t.btxID)...) + for i, index := range t.indexes { + index <<= 1 + if i == len(t.indexes)-1 { + index |= 1 + } + l := packVarint32(index, bvout) + buf = append(buf, bvout[:l]...) + } + } + 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) getTxAddresses(btxID []byte) (*bchain.TxAddresses, error) { - val, err := d.db.GetCF(d.ro, d.cfh[cfTxAddresses], btxID) - if err != nil { - return nil, err +func (d *RocksDB) packOutpoints(outpoints []outpoint) []byte { + buf := make([]byte, 0, 32) + bvout := make([]byte, vlq.MaxLen32) + for _, o := range outpoints { + l := packVarint32(o.index, bvout) + buf = append(buf, []byte(o.btxID)...) + buf = append(buf, bvout[:l]...) } - defer val.Free() - buf := val.Data() - // 2 is minimum length of addrBalance - 1 byte height, 1 byte inputs len, 1 byte outputs len - if len(buf) < 3 { - return nil, nil + return buf +} + +func (d *RocksDB) unpackNOutpoints(buf []byte) ([]outpoint, int, error) { + txidUnpackedLen := d.chainParser.PackedTxidLen() + n, p := unpackVaruint(buf) + outpoints := make([]outpoint, n) + for i := uint(0); i < n; i++ { + if p+txidUnpackedLen >= len(buf) { + return nil, 0, errors.New("Inconsistent data in unpackNOutpoints") + } + btxID := append([]byte(nil), buf[p:p+txidUnpackedLen]...) + p += txidUnpackedLen + vout, voutLen := unpackVarint32(buf[p:]) + p += voutLen + outpoints[i] = outpoint{ + btxID: btxID, + index: vout, + } } - return d.chainParser.UnpackTxAddresses(buf) + return outpoints, p, nil } -// GetTxAddresses returns TxAddresses for given txid or nil if not found -func (d *RocksDB) GetTxAddresses(txid string) (*bchain.TxAddresses, error) { - btxID, err := d.chainParser.PackTxid(txid) +// Block index + +// BlockInfo holds information about blocks kept in column height +type BlockInfo struct { + Hash string + Time int64 + Txs uint32 + Size uint32 + Height uint32 // Height is not packed! +} + +func (d *RocksDB) packBlockInfo(block *BlockInfo) ([]byte, error) { + packed := make([]byte, 0, 64) + varBuf := make([]byte, vlq.MaxLen64) + b, err := d.chainParser.PackBlockHash(block.Hash) if err != nil { return nil, err } - return d.getTxAddresses(btxID) + pl := d.chainParser.PackedTxidLen() + if len(b) != pl { + glog.Warning("Non standard block hash for height ", block.Height, ", hash [", block.Hash, "]") + if len(b) > pl { + b = b[:pl] + } else { + b = append(b, make([]byte, pl-len(b))...) + } + } + packed = append(packed, b...) + packed = append(packed, packUint(uint32(block.Time))...) + l := packVaruint(uint(block.Txs), varBuf) + packed = append(packed, varBuf[:l]...) + l = packVaruint(uint(block.Size), varBuf) + packed = append(packed, varBuf[:l]...) + return packed, nil } -// AddrDescForOutpoint is a function that returns address descriptor and value for given outpoint or nil if outpoint not found -func (d *RocksDB) AddrDescForOutpoint(outpoint bchain.Outpoint) (bchain.AddressDescriptor, *big.Int) { - ta, err := d.GetTxAddresses(outpoint.Txid) - if err != nil || ta == nil { +func (d *RocksDB) unpackBlockInfo(buf []byte) (*BlockInfo, error) { + pl := d.chainParser.PackedTxidLen() + // minimum length is PackedTxidLen + 4 bytes time + 1 byte txs + 1 byte size + if len(buf) < pl+4+2 { return nil, nil } - if outpoint.Vout < 0 { - vin := ^outpoint.Vout - if len(ta.Inputs) <= int(vin) { - return nil, nil - } - return ta.Inputs[vin].AddrDesc, &ta.Inputs[vin].ValueSat - } - if len(ta.Outputs) <= int(outpoint.Vout) { - return nil, nil + txid, err := d.chainParser.UnpackBlockHash(buf[:pl]) + if err != nil { + return nil, err } - return ta.Outputs[outpoint.Vout].AddrDesc, &ta.Outputs[outpoint.Vout].ValueSat + t := unpackUint(buf[pl:]) + txs, l := unpackVaruint(buf[pl+4:]) + size, _ := unpackVaruint(buf[pl+4+l:]) + return &BlockInfo{ + Hash: txid, + Time: int64(t), + Txs: uint32(txs), + Size: uint32(size), + }, nil } // GetBestBlock returns the block hash of the block with highest height in the db @@ -947,8 +1690,8 @@ func (d *RocksDB) GetBestBlock() (uint32, string, error) { it := d.db.NewIteratorCF(d.ro, d.cfh[cfHeight]) defer it.Close() if it.SeekToLast(); it.Valid() { - bestHeight := d.chainParser.UnpackUint(it.Key().Data()) - info, err := d.chainParser.UnpackBlockInfo(it.Value().Data()) + bestHeight := unpackUint(it.Key().Data()) + info, err := d.unpackBlockInfo(it.Value().Data()) if info != nil { if glog.V(1) { glog.Infof("rocksdb: bestblock %d %+v", bestHeight, info) @@ -961,13 +1704,13 @@ func (d *RocksDB) GetBestBlock() (uint32, string, error) { // GetBlockHash returns block hash at given height or empty string if not found func (d *RocksDB) GetBlockHash(height uint32) (string, error) { - key := d.chainParser.PackUint(height) + key := packUint(height) val, err := d.db.GetCF(d.ro, d.cfh[cfHeight], key) if err != nil { return "", err } defer val.Free() - info, err := d.chainParser.UnpackBlockInfo(val.Data()) + info, err := d.unpackBlockInfo(val.Data()) if info == nil { return "", err } @@ -975,14 +1718,14 @@ func (d *RocksDB) GetBlockHash(height uint32) (string, error) { } // GetBlockInfo returns block info stored in db -func (d *RocksDB) GetBlockInfo(height uint32) (*bchain.DbBlockInfo, error) { - key := d.chainParser.PackUint(height) +func (d *RocksDB) GetBlockInfo(height uint32) (*BlockInfo, error) { + key := packUint(height) val, err := d.db.GetCF(d.ro, d.cfh[cfHeight], key) if err != nil { return nil, err } defer val.Free() - bi, err := d.chainParser.UnpackBlockInfo(val.Data()) + bi, err := d.unpackBlockInfo(val.Data()) if err != nil || bi == nil { return nil, err } @@ -990,8 +1733,8 @@ func (d *RocksDB) GetBlockInfo(height uint32) (*bchain.DbBlockInfo, error) { return bi, err } -func (d *RocksDB) writeHeightFromBlock(wb *gorocksdb.WriteBatch, block *bchain.Block, op int) error { - return d.writeHeight(wb, block.Height, &bchain.DbBlockInfo{ +func (d *RocksDB) writeHeightFromBlock(wb *grocksdb.WriteBatch, block *bchain.Block, op int) error { + return d.writeHeight(wb, block.Height, &BlockInfo{ Hash: block.Hash, Time: block.Time, Txs: uint32(len(block.Txs)), @@ -1000,11 +1743,11 @@ func (d *RocksDB) writeHeightFromBlock(wb *gorocksdb.WriteBatch, block *bchain.B }, op) } -func (d *RocksDB) writeHeight(wb *gorocksdb.WriteBatch, height uint32, bi *bchain.DbBlockInfo, op int) error { - key := d.chainParser.PackUint(height) +func (d *RocksDB) writeHeight(wb *grocksdb.WriteBatch, height uint32, bi *BlockInfo, op int) error { + key := packUint(height) switch op { case opInsert: - val, err := d.chainParser.PackBlockInfo(bi) + val, err := d.packBlockInfo(bi) if err != nil { return err } @@ -1017,22 +1760,57 @@ func (d *RocksDB) writeHeight(wb *gorocksdb.WriteBatch, height uint32, bi *bchai return nil } +// address alias support +var cachedAddressAliasRecords = newAddressAliasLRU(cachedAddressAliasRecordsLRUMaxSize) + +func (d *RocksDB) GetAddressAlias(address string) string { + 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 { + if d.chainParser.UseAddressAliases() { + for i := range records { + r := &records[i] + if len(r.Name) > 0 { + wb.PutCF(d.cfh[cfAddressAliases], []byte(r.Address), []byte(r.Name)) + cachedAddressAliasRecords.add(r.Address, d.chainParser.FormatAddressAlias(r.Address, r.Name)) + } + } + } + return nil +} + // Disconnect blocks -func (d *RocksDB) disconnectTxAddressesInputs(btxID []byte, inputs []bchain.DbOutpoint, txa *bchain.TxAddresses, txAddressesToUpdate map[string]*bchain.TxAddresses, - getAddressBalance func(addrDesc bchain.AddressDescriptor) (*bchain.AddrBalance, error), + +func (d *RocksDB) disconnectTxAddressesInputs(wb *grocksdb.WriteBatch, btxID []byte, inputs []outpoint, txa *TxAddresses, txAddressesToUpdate map[string]*TxAddresses, + getAddressBalance func(addrDesc bchain.AddressDescriptor) (*AddrBalance, error), addressFoundInTx func(addrDesc bchain.AddressDescriptor, btxID []byte) bool, - assetFoundInTx func(asset uint64, btxID []byte) bool, - assets map[uint64]*bchain.Asset, - blockTxAssetAddresses bchain.TxAssetAddressMap) error { + assetFoundInTx func(asset uint64, btxID []byte) bool, blockTxAssetAddresses bchain.TxAssetAddressMap, assets map[uint64]*bchain.Asset) error { var err error + var balance *AddrBalance for i, t := range txa.Inputs { if len(t.AddrDesc) > 0 { input := &inputs[i] exist := addressFoundInTx(t.AddrDesc, btxID) - s := string(input.BtxID) + s := string(input.btxID) sa, found := txAddressesToUpdate[s] if !found { - sa, err = d.getTxAddresses(input.BtxID) + sa, err = d.getTxAddresses(input.btxID) if err != nil { return err } @@ -1042,11 +1820,11 @@ func (d *RocksDB) disconnectTxAddressesInputs(btxID []byte, inputs []bchain.DbOu } var inputHeight uint32 if sa != nil { - sa.Outputs[input.Index].Spent = false + sa.Outputs[input.index].Spent = false inputHeight = sa.Height } if d.chainParser.IsAddrDescIndexable(t.AddrDesc) { - balance, err := getAddressBalance(t.AddrDesc) + balance, err = getAddressBalance(t.AddrDesc) if err != nil { return err } @@ -1060,23 +1838,24 @@ func (d *RocksDB) disconnectTxAddressesInputs(btxID []byte, inputs []bchain.DbOu d.resetValueSatToZero(&balance.SentSat, t.AddrDesc, "sent amount") } balance.BalanceSat.Add(&balance.BalanceSat, &t.ValueSat) - balance.AddUtxoInDisconnect(&bchain.Utxo{ - BtxID: input.BtxID, - Vout: input.Index, - Height: inputHeight, - ValueSat: t.ValueSat, - AssetInfo: t.AssetInfo, + balance.addUtxoInDisconnect(&Utxo{ + BtxID: input.btxID, + Vout: input.index, + Height: inputHeight, + ValueSat: t.ValueSat, + AssetInfo: t.AssetInfo, // SYSCOIN }) + // SYSCOIN: disconnecting an input restores the spent SPT UTXO. if t.AssetInfo != nil { if balance.AssetBalances == nil { - return errors.New("DisconnectSyscoinInput asset balances was nil but not expected to be") + balance.AssetBalances = make(map[uint64]*bchain.AssetBalance) } - balanceAsset, ok := balance.AssetBalances[t.AssetInfo.AssetGuid] - if !ok { - return errors.New("DisconnectSyscoinInput asset balance not found") + balanceAsset := balance.AssetBalances[t.AssetInfo.AssetGuid] + if balanceAsset == nil { + balanceAsset = &bchain.AssetBalance{BalanceSat: new(big.Int), SentSat: new(big.Int)} + balance.AssetBalances[t.AssetInfo.AssetGuid] = balanceAsset } - err := d.DisconnectAllocationInput(&t.AddrDesc, balanceAsset, btxID, t.AssetInfo, blockTxAssetAddresses, assets, assetFoundInTx) - if err != nil { + if err := d.DisconnectAllocationInput(&t.AddrDesc, balanceAsset, btxID, t.AssetInfo, blockTxAssetAddresses, assets, assetFoundInTx); err != nil { return err } } @@ -1089,12 +1868,11 @@ func (d *RocksDB) disconnectTxAddressesInputs(btxID []byte, inputs []bchain.DbOu } return nil } -func (d *RocksDB) disconnectTxAddressesOutputs(btxID []byte, txa *bchain.TxAddresses, - getAddressBalance func(addrDesc bchain.AddressDescriptor) (*bchain.AddrBalance, error), + +func (d *RocksDB) disconnectTxAddressesOutputs(wb *grocksdb.WriteBatch, btxID []byte, txa *TxAddresses, + getAddressBalance func(addrDesc bchain.AddressDescriptor) (*AddrBalance, error), addressFoundInTx func(addrDesc bchain.AddressDescriptor, btxID []byte) bool, - blockTxAssetAddresses bchain.TxAssetAddressMap, - assetFoundInTx func(asset uint64, btxID []byte) bool, - assets map[uint64]*bchain.Asset) error { + assetFoundInTx func(asset uint64, btxID []byte) bool, blockTxAssetAddresses bchain.TxAssetAddressMap, assets map[uint64]*bchain.Asset) error { for i, t := range txa.Outputs { if len(t.AddrDesc) > 0 { exist := addressFoundInTx(t.AddrDesc, btxID) @@ -1112,20 +1890,21 @@ func (d *RocksDB) disconnectTxAddressesOutputs(btxID []byte, txa *bchain.TxAddre if balance.BalanceSat.Sign() < 0 { d.resetValueSatToZero(&balance.BalanceSat, t.AddrDesc, "balance") } - balance.MarkUtxoAsSpent(btxID, int32(i)) + // SYSCOIN: disconnecting an output removes the created SPT UTXO. if t.AssetInfo != nil { if balance.AssetBalances == nil { - return errors.New("DisconnectSyscoinOutput asset balances was nil but not expected to be") + balance.AssetBalances = make(map[uint64]*bchain.AssetBalance) } - balanceAsset, ok := balance.AssetBalances[t.AssetInfo.AssetGuid] - if !ok { - return errors.New("DisconnectSyscoinOutput asset balance not found") + balanceAsset := balance.AssetBalances[t.AssetInfo.AssetGuid] + if balanceAsset == nil { + balanceAsset = &bchain.AssetBalance{BalanceSat: new(big.Int), SentSat: new(big.Int)} + balance.AssetBalances[t.AssetInfo.AssetGuid] = balanceAsset } - err := d.DisconnectAllocationOutput(&t.AddrDesc, balanceAsset, btxID, t.AssetInfo, blockTxAssetAddresses, assets, assetFoundInTx) - if err != nil { + if err := d.DisconnectAllocationOutput(&t.AddrDesc, balanceAsset, btxID, t.AssetInfo, blockTxAssetAddresses, assets, assetFoundInTx); err != nil { return err } } + balance.markUtxoAsSpent(btxID, int32(i)) } else { ad, _, _ := d.chainParser.GetAddressesFromAddrDesc(t.AddrDesc) glog.Warningf("Balance for address %s (%s) not found", ad, t.AddrDesc) @@ -1135,21 +1914,34 @@ func (d *RocksDB) disconnectTxAddressesOutputs(btxID []byte, txa *bchain.TxAddre } return nil } -func (d *RocksDB) disconnectBlock(height uint32, blockTxs []bchain.BlockTxs) error { - wb := gorocksdb.NewWriteBatch() + +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() - txAddressesToUpdate := make(map[string]*bchain.TxAddresses) - txAddresses := make([]*bchain.TxAddresses, len(blockTxs)) + txAddressesToUpdate := make(map[string]*TxAddresses) + txAddresses := make([]*TxAddresses, len(blockTxs)) txsToDelete := make(map[string]struct{}) - blockTxAssetAddresses := make(bchain.TxAssetAddressMap) - balances := make(map[string]*bchain.AddrBalance) - assets := make(map[uint64]*bchain.Asset) - getAddressBalance := func(addrDesc bchain.AddressDescriptor) (*bchain.AddrBalance, error) { + + balances := make(map[string]*AddrBalance) + getAddressBalance := func(addrDesc bchain.AddressDescriptor) (*AddrBalance, error) { var err error s := string(addrDesc) b, fb := balances[s] if !fb { - b, err = d.GetAddrDescBalance(addrDesc, bchain.AddressBalanceDetailUTXOIndexed) + b, err = d.GetAddrDescBalance(addrDesc, addressBalanceDetailUTXOIndexed) if err != nil { return nil, err } @@ -1160,6 +1952,12 @@ func (d *RocksDB) disconnectBlock(height uint32, blockTxs []bchain.BlockTxs) err // all addresses in the block are stored in blockAddressesTxs, together with a map of transactions where they appear blockAddressesTxs := make(map[string]map[string]struct{}) + // SYSCOIN: track SPT assets seen while reversing this block so asset tx + // counts and per-height txAsset rows are updated once per asset/tx. + blockAssetTxs := make(map[uint64]map[string]struct{}) + blockTxAssetAddresses := make(bchain.TxAssetAddressMap) + assets := make(map[uint64]*bchain.Asset) + touchedAssets := make(map[uint64]struct{}) // addressFoundInTx handles updates of the blockAddressesTxs map and returns true if the address+tx was already encountered addressFoundInTx := func(addrDesc bchain.AddressDescriptor, btxID []byte) bool { sAddrDesc := string(addrDesc) @@ -1175,27 +1973,27 @@ func (d *RocksDB) disconnectBlock(height uint32, blockTxs []bchain.BlockTxs) err } return exist } - // all assets in the block are stored in blockAssetsTxs, together with a map of transactions where they appear - blockAssetsTxs := make(map[uint64]map[string]struct{}) - // assetFoundInTx handles updates of the blockAssetsTxs map and returns true if the asset+tx was already encountered + // SYSCOIN assetFoundInTx := func(asset uint64, btxID []byte) bool { + touchedAssets[asset] = struct{}{} sBtxID := string(btxID) - a, exist := blockAssetsTxs[asset] + a, exist := blockAssetTxs[asset] if !exist { - blockAssetsTxs[asset] = map[string]struct{}{sBtxID: {}} - } else { - _, exist = a[sBtxID] - if !exist { - a[sBtxID] = struct{}{} - } + blockAssetTxs[asset] = map[string]struct{}{sBtxID: {}} + return false + } + _, exist = a[sBtxID] + if !exist { + a[sBtxID] = struct{}{} } return exist } + glog.Info("Disconnecting block ", height, " containing ", len(blockTxs), " transactions") // when connecting block, outputs are processed first // when disconnecting, inputs must be reversed first for i := range blockTxs { - btxID := blockTxs[i].BtxID + btxID := blockTxs[i].btxID s := string(btxID) txsToDelete[s] = struct{}{} txa, err := d.getTxAddresses(btxID) @@ -1208,47 +2006,58 @@ func (d *RocksDB) disconnectBlock(height uint32, blockTxs []bchain.BlockTxs) err continue } txAddresses[i] = txa - if err := d.disconnectTxAddressesInputs(btxID, blockTxs[i].Inputs, txa, txAddressesToUpdate, getAddressBalance, addressFoundInTx, assetFoundInTx, assets, blockTxAssetAddresses); err != nil { + if err := d.disconnectTxAddressesInputs(wb, btxID, blockTxs[i].inputs, txa, txAddressesToUpdate, getAddressBalance, addressFoundInTx, assetFoundInTx, blockTxAssetAddresses, assets); err != nil { return err } } for i := range blockTxs { - btxID := blockTxs[i].BtxID + btxID := blockTxs[i].btxID txa := txAddresses[i] if txa == nil { continue } - if err := d.disconnectTxAddressesOutputs(btxID, txa, getAddressBalance, addressFoundInTx, blockTxAssetAddresses, assetFoundInTx, assets); err != nil { + if err := d.disconnectTxAddressesOutputs(wb, btxID, txa, getAddressBalance, addressFoundInTx, assetFoundInTx, blockTxAssetAddresses, assets); err != nil { return err } } for a := range blockAddressesTxs { - key := d.chainParser.PackAddressKey([]byte(a), height) + key := packAddressKey([]byte(a), height) wb.DeleteCF(d.cfh[cfAddresses], key) - key = d.chainParser.PackAddressKey([]byte(a), height) - } - for a := range blockAssetsTxs { - key := d.chainParser.PackAssetKey(a, height) - wb.DeleteCF(d.cfh[cfTxAssets], key) } - key := d.chainParser.PackUint(height) + key := packUint(height) wb.DeleteCF(d.cfh[cfBlockTxs], key) wb.DeleteCF(d.cfh[cfHeight], key) d.storeTxAddresses(wb, txAddressesToUpdate) d.storeBalancesDisconnect(wb, balances) - d.storeAssets(wb, assets) + // SYSCOIN: remove per-asset transaction index rows for the disconnected + // height and persist adjusted asset metadata. + if supportsSyscoinAssets(d.chainParser) { + parser := d.syscoinAssetParser() + if parser == nil { + return errors.New("disconnectBlock: Syscoin asset parser is not configured") + } + for asset := range touchedAssets { + wb.DeleteCF(d.cfh[cfTxAssets], parser.PackAssetKey(asset, height)) + } + if err := d.storeAssets(wb, assets); err != nil { + return err + } + } for s := range txsToDelete { b := []byte(s) wb.DeleteCF(d.cfh[cfTransactions], b) wb.DeleteCF(d.cfh[cfTxAddresses], b) } - return d.db.Write(d.wo, wb) + if err := d.disconnectBlockFilter(wb, height); err != nil { + return err + } + return d.WriteBatch(wb) } // DisconnectBlockRangeBitcoinType 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 func (d *RocksDB) DisconnectBlockRangeBitcoinType(lower uint32, higher uint32) error { - blocks := make([][]bchain.BlockTxs, higher-lower+1) + blocks := make([][]blockTxs, higher-lower+1) for height := lower; height <= higher; height++ { blockTxs, err := d.getBlockTxs(height) if err != nil { @@ -1270,11 +2079,11 @@ func (d *RocksDB) DisconnectBlockRangeBitcoinType(lower uint32, higher uint32) e return nil } -func (d *RocksDB) storeBalancesDisconnect(wb *gorocksdb.WriteBatch, balances map[string]*bchain.AddrBalance) { +func (d *RocksDB) storeBalancesDisconnect(wb *grocksdb.WriteBatch, balances map[string]*AddrBalance) { for _, b := range balances { if b != nil { // remove spent utxos - us := make([]bchain.Utxo, 0, len(b.Utxos)) + us := make([]Utxo, 0, len(b.Utxos)) for _, u := range b.Utxos { // remove utxos marked as spent if u.Vout >= 0 { @@ -1304,13 +2113,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 } @@ -1356,14 +2178,14 @@ func (d *RocksDB) DeleteTx(txid string) error { return nil } // use write batch so that this delete matches other deletes - wb := gorocksdb.NewWriteBatch() + wb := grocksdb.NewWriteBatch() defer wb.Destroy() d.internalDeleteTx(wb, key) - return d.db.Write(d.wo, wb) + return d.WriteBatch(wb) } // internalDeleteTx checks if tx is cached and updates internal state accordingly -func (d *RocksDB) internalDeleteTx(wb *gorocksdb.WriteBatch, key []byte) { +func (d *RocksDB) internalDeleteTx(wb *grocksdb.WriteBatch, key []byte) { val, err := d.db.GetCF(d.ro, d.cfh[cfTransactions], key) // ignore error, it is only for statistics if err == nil { @@ -1386,7 +2208,7 @@ func (d *RocksDB) loadBlockTimes() ([]uint32, error) { counter := uint32(0) time := uint32(0) for it.SeekToFirst(); it.Valid(); it.Next() { - height := d.chainParser.UnpackUint(it.Key().Data()) + height := unpackUint(it.Key().Data()) if height > counter { glog.Warning("gap in cfHeight: expecting ", counter, ", got ", height) for ; counter < height; counter++ { @@ -1394,7 +2216,7 @@ func (d *RocksDB) loadBlockTimes() ([]uint32, error) { } } counter++ - info, err := d.chainParser.UnpackBlockInfo(it.Value().Data()) + info, err := d.unpackBlockInfo(it.Value().Data()) if err != nil { return nil, err } @@ -1403,34 +2225,106 @@ func (d *RocksDB) loadBlockTimes() ([]uint32, error) { } times = append(times, time) } - glog.Info("loaded ", len(times), " block times") return times, nil } -// LoadInternalState loads from db internal state or initializes a new one if not yet stored -func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, error) { - val, err := d.db.GetCF(d.ro, d.cfh[cfDefault], []byte(internalStateKey)) +func (d *RocksDB) setBlockTimes() { + start := time.Now() + bt, err := d.loadBlockTimes() if err != nil { - return nil, err + glog.Error("rocksdb: cannot load block times ", err) + return } - defer val.Free() - data := val.Data() - var is *common.InternalState - if len(data) == 0 { - is = &common.InternalState{Coin: rpcCoin, UtxoChecked: true} + 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 { - is, err = common.UnpackInternalState(data) + 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 nil, err + return errors.Errorf("error storing repacked data %v", err) } - // 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) + + 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 nc := make([]common.InternalStateColumn, len(cfNames)) @@ -1441,7 +2335,19 @@ func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, erro 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 { - 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) + 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 + } + } 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) + } } nc[i].Rows = sc[j].Rows nc[i].KeyBytes = sc[j].KeyBytes @@ -1451,17 +2357,85 @@ func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, erro } } } - is.DbColumns = nc - is.BlockTimes, err = d.loadBlockTimes() + return nc, nil +} + +// LoadInternalState loads from db internal state or initializes a new one if not yet stored +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 + } + defer val.Free() + data := val.Data() + var is *common.InternalState + if len(data) == 0 { + 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 { + return nil, err + } + // verify that the rpc coin matches DB coin + // running it mismatched would corrupt the database + if is.Coin == "" { + 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) if err != nil { return nil, err } + is.DbColumns = nc + + 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 var t time.Time is.LastMempoolSync = t is.SyncMode = false + + is.CoinShortcut = config.CoinShortcut + if config.CoinLabel == "" { + is.CoinLabel = config.CoinName + } else { + is.CoinLabel = config.CoinLabel + } + is.Network = config.Network + return is, nil } @@ -1484,6 +2458,11 @@ func (d *RocksDB) SetInternalState(is *common.InternalState) { d.is = is } +// GetInternalState gets the InternalState +func (d *RocksDB) GetInternalState() *common.InternalState { + return d.is +} + // StoreInternalState stores the internal state to db func (d *RocksDB) StoreInternalState(is *common.InternalState) error { if d.metrics != nil { @@ -1508,7 +2487,8 @@ func (d *RocksDB) computeColumnSize(col int, stopCompute chan os.Signal) (int64, var rows, keysSum, valuesSum int64 var seekKey []byte // do not use cache - ro := gorocksdb.NewDefaultReadOptions() + ro := grocksdb.NewDefaultReadOptions() + defer ro.Destroy() ro.SetFillCache(false) for { var key []byte @@ -1526,13 +2506,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 { @@ -1555,11 +2535,11 @@ func (d *RocksDB) ComputeInternalStateColumnStats(stopCompute chan os.Signal) er d.is.SetDBColumnStats(c, rows, keysSum, valuesSum) glog.Info("db: Column ", cfNames[c], ": rows ", rows, ", key bytes ", keysSum, ", value bytes ", valuesSum) } - glog.Info("db: ComputeInternalStateColumnStats, ", time.Since(start)) + glog.Info("db: ComputeInternalStateColumnStats finished in ", time.Since(start)) return nil } -func reorderUtxo(utxos []bchain.Utxo, index int) { +func reorderUtxo(utxos []Utxo, index int) { var from, to int for from = index; from >= 0; from-- { if !bytes.Equal(utxos[from].BtxID, utxos[index].BtxID) { @@ -1579,10 +2559,10 @@ func reorderUtxo(utxos []bchain.Utxo, index int) { } -func (d *RocksDB) fixUtxo(addrDesc bchain.AddressDescriptor, ba *bchain.AddrBalance) (bool, bool, error) { +func (d *RocksDB) fixUtxo(addrDesc bchain.AddressDescriptor, ba *AddrBalance) (bool, bool, error) { reorder := false var checksum big.Int - var prevUtxo *bchain.Utxo + var prevUtxo *Utxo for i := range ba.Utxos { utxo := &ba.Utxos[i] checksum.Add(&checksum, &utxo.ValueSat) @@ -1604,9 +2584,9 @@ func (d *RocksDB) fixUtxo(addrDesc bchain.AddressDescriptor, ba *bchain.AddrBala } if checksum.Cmp(&ba.BalanceSat) != 0 { var checksumFromTxs big.Int - var utxos []bchain.Utxo - err := d.GetAddrDescTransactions(addrDesc, 0, ^uint32(0), bchain.AllMask, func(txid string, height uint32, assetGuids []uint64, indexes []int32) error { - var ta *bchain.TxAddresses + var utxos []Utxo + err := d.GetAddrDescTransactions(addrDesc, 0, ^uint32(0), func(txid string, height uint32, indexes []int32) error { + var ta *TxAddresses var err error // sort the indexes so that the utxos are appended in the reverse order sort.Slice(indexes, func(i, j int) bool { @@ -1633,7 +2613,7 @@ func (d *RocksDB) fixUtxo(addrDesc bchain.AddressDescriptor, ba *bchain.AddrBala if !tao.Spent { bTxid, _ := d.chainParser.PackTxid(txid) checksumFromTxs.Add(&checksumFromTxs, &tao.ValueSat) - utxos = append(utxos, bchain.Utxo{AssetInfo: tao.AssetInfo, BtxID: bTxid, Height: height, Vout: index, ValueSat: tao.ValueSat}) + utxos = append(utxos, Utxo{BtxID: bTxid, Height: height, Vout: index, ValueSat: tao.ValueSat}) if checksumFromTxs.Cmp(&ba.BalanceSat) == 0 { return &StopIteration{} } @@ -1653,10 +2633,10 @@ func (d *RocksDB) fixUtxo(addrDesc bchain.AddressDescriptor, ba *bchain.AddrBala utxos[i], utxos[opp] = utxos[opp], utxos[i] } ba.Utxos = utxos - wb := gorocksdb.NewWriteBatch() - err = d.storeBalances(wb, map[string]*bchain.AddrBalance{string(addrDesc): ba}) + wb := grocksdb.NewWriteBatch() + err = d.storeBalances(wb, map[string]*AddrBalance{string(addrDesc): ba}) if err == nil { - err = d.db.Write(d.wo, wb) + err = d.WriteBatch(wb) } wb.Destroy() if err != nil { @@ -1666,10 +2646,10 @@ func (d *RocksDB) fixUtxo(addrDesc bchain.AddressDescriptor, ba *bchain.AddrBala } return fixed, false, errors.Errorf("balance %s, checksum %s, from txa %s, txs %d", ba.BalanceSat.String(), checksum.String(), checksumFromTxs.String(), ba.Txs) } else if reorder { - wb := gorocksdb.NewWriteBatch() - err := d.storeBalances(wb, map[string]*bchain.AddrBalance{string(addrDesc): ba}) + wb := grocksdb.NewWriteBatch() + err := d.storeBalances(wb, map[string]*AddrBalance{string(addrDesc): ba}) if err == nil { - err = d.db.Write(d.wo, wb) + err = d.WriteBatch(wb) } wb.Destroy() if err != nil { @@ -1689,7 +2669,8 @@ func (d *RocksDB) FixUtxos(stop chan os.Signal) error { var row, errorsCount, fixedCount int64 var seekKey []byte // do not use cache - ro := gorocksdb.NewDefaultReadOptions() + ro := grocksdb.NewDefaultReadOptions() + defer ro.Destroy() ro.SetFillCache(false) for { var addrDesc bchain.AddressDescriptor @@ -1707,7 +2688,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++ @@ -1716,7 +2697,7 @@ func (d *RocksDB) FixUtxos(stop chan os.Signal) error { errorsCount++ continue } - ba, err := d.chainParser.UnpackAddrBalance(buf, d.chainParser.PackedTxidLen(), bchain.AddressBalanceDetailUTXO) + ba, err := d.unpackAddrBalance(buf, d.chainParser.PackedTxidLen(), AddressBalanceDetailUTXO) if err != nil { glog.Error("FixUtxos: row ", row, ", addrDesc ", addrDesc, ", unpackAddrBalance error ", err) errorsCount++ @@ -1734,7 +2715,7 @@ func (d *RocksDB) FixUtxos(stop chan os.Signal) error { fixedCount++ } } - seekKey = append([]byte{}, addrDesc...) + seekKey = addrDesc valid := it.Valid() it.Close() if !valid { @@ -1744,3 +2725,218 @@ func (d *RocksDB) FixUtxos(stop chan os.Signal) error { glog.Info("FixUtxos: finished, scanned ", row, " rows, found ", errorsCount, " errors, fixed ", fixedCount) 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 { + buf := make([]byte, len(addrDesc)+packedHeightBytes) + copy(buf, addrDesc) + // pack height as binary complement to achieve ordering from newest to oldest block + binary.BigEndian.PutUint32(buf[len(addrDesc):], ^height) + return buf +} + +func unpackAddressKey(key []byte) ([]byte, uint32, error) { + i := len(key) - packedHeightBytes + if i <= 0 { + return nil, 0, errors.New("Invalid address key") + } + // height is packed in binary complement, convert it + return key[:i], ^unpackUint(key[i : i+packedHeightBytes]), nil +} + +func packUint(i uint32) []byte { + buf := make([]byte, 4) + binary.BigEndian.PutUint32(buf, i) + return buf +} + +func unpackUint(buf []byte) uint32 { + return binary.BigEndian.Uint32(buf) +} + +func packVarint32(i int32, buf []byte) int { + return vlq.PutInt(buf, int64(i)) +} + +func packVarint(i int, buf []byte) int { + return vlq.PutInt(buf, int64(i)) +} + +func packVaruint(i uint, buf []byte) int { + return vlq.PutUint(buf, uint64(i)) +} + +func packVaruint64(i uint64, buf []byte) int { + return vlq.PutUint(buf, i) +} + +func unpackVarint32(buf []byte) (int32, int) { + i, ofs := vlq.Int(buf) + return int32(i), ofs +} + +func unpackVarint(buf []byte) (int, int) { + i, ofs := vlq.Int(buf) + return int(i), ofs +} + +func unpackVaruint(buf []byte) (uint, int) { + i, ofs := vlq.Uint(buf) + return uint(i), ofs +} + +func unpackVaruint64(buf []byte) (uint64, int) { + return vlq.Uint(buf) +} + +func packVarBytes(value []byte) []byte { + varBuf := make([]byte, vlq.MaxLen64) + l := packVaruint(uint(len(value)), varBuf) + buf := make([]byte, 0, l+len(value)) + buf = append(buf, varBuf[:l]...) + buf = append(buf, value...) + return buf +} + +func unpackVarBytes(buf []byte) ([]byte, int) { + l, ll := unpackVaruint(buf) + return append([]byte(nil), buf[ll:ll+int(l)]...), ll + int(l) +} + +func appendAssetInfo(assetInfo *bchain.AssetInfo, buf []byte, varBuf []byte) []byte { + if assetInfo == nil { + l := packVaruint(0, varBuf) + return append(buf, varBuf[:l]...) + } + l := packVaruint(1, varBuf) + buf = append(buf, varBuf[:l]...) + l = packVaruint64(assetInfo.AssetGuid, varBuf) + buf = append(buf, varBuf[:l]...) + valueSat := assetInfo.ValueSat + if valueSat == nil { + valueSat = new(big.Int) + } + l = packBigint(valueSat, varBuf) + buf = append(buf, varBuf[:l]...) + return buf +} + +func unpackAssetInfo(buf []byte) (*bchain.AssetInfo, int) { + flag, l := unpackVaruint(buf) + if flag == 0 { + return nil, l + } + assetGuid, ll := unpackVaruint64(buf[l:]) + l += ll + valueSat, ll := unpackBigint(buf[l:]) + l += ll + return &bchain.AssetInfo{AssetGuid: assetGuid, ValueSat: &valueSat}, l +} + +func packString(s string) []byte { + varBuf := make([]byte, vlq.MaxLen64) + l := len(s) + i := packVaruint(uint(l), varBuf) + buf := make([]byte, 0, i+l) + buf = append(buf, varBuf[:i]...) + buf = append(buf, s...) + return buf +} + +func unpackString(buf []byte) (string, int) { + sl, l := unpackVaruint(buf) + so := l + int(sl) + s := string(buf[l:so]) + return s, so +} + +const ( + // number of bits in a big.Word + wordBits = 32 << (uint64(^big.Word(0)) >> 63) + // number of bytes in a big.Word + wordBytes = wordBits / 8 + // max packed bigint words + maxPackedBigintWords = (256 - wordBytes) / wordBytes + maxPackedBigintBytes = 249 +) + +// big int is packed in BigEndian order without memory allocation as 1 byte length followed by bytes of big int +// number of written bytes is returned +// limitation: big ints longer than 248 bytes are truncated to 248 bytes +// caution: buffer must be big enough to hold the packed big int, buffer 249 bytes big is always safe +func packBigint(bi *big.Int, buf []byte) int { + w := bi.Bits() + lw := len(w) + // zero returns only one byte - zero length + if lw == 0 { + buf[0] = 0 + return 1 + } + // pack the most significant word in a special way - skip leading zeros + w0 := w[lw-1] + fb := 8 + mask := big.Word(0xff) << (wordBits - 8) + for w0&mask == 0 { + fb-- + mask >>= 8 + } + for i := fb; i > 0; i-- { + buf[i] = byte(w0) + w0 >>= 8 + } + // if the big int is too big (> 2^1984), the number of bytes would not fit to 1 byte + // in this case, truncate the number, it is not expected to work with this big numbers as amounts + s := 0 + if lw > maxPackedBigintWords { + s = lw - maxPackedBigintWords + } + // pack the rest of the words in reverse order + for j := lw - 2; j >= s; j-- { + d := w[j] + for i := fb + wordBytes; i > fb; i-- { + buf[i] = byte(d) + d >>= 8 + } + fb += wordBytes + } + buf[0] = byte(fb) + 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 + r.SetBytes(buf[1:l]) + return r, l +} 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 63db9d8dd5..cf17c39597 100644 --- a/db/rocksdb_ethereumtype.go +++ b/db/rocksdb_ethereumtype.go @@ -3,88 +3,352 @@ package db import ( "bytes" "encoding/hex" + "math/big" + "os" + "sort" + "sync" + "time" - vlq "github.com/bsm/go-vlq" - "github.com/flier/gorocksdb" "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/eth" + "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 { - Contract bchain.AddressDescriptor - Txs uint + Standard bchain.TokenStandard + Contract bchain.AddressDescriptor + Txs uint + 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 type AddrContracts struct { TotalTxs uint NonContractTxs uint + InternalTxs uint Contracts []AddrContract } -func (d *RocksDB) storeAddressContracts(wb *gorocksdb.WriteBatch, acm map[string]*AddrContracts) error { - buf := make([]byte, 64) - varBuf := make([]byte, vlq.MaxLen64) - for addrDesc, acs := range acm { - // address with 0 contracts is removed from db - happens on disconnect - if acs == nil || (acs.NonContractTxs == 0 && len(acs.Contracts) == 0) { - wb.DeleteCF(d.cfh[cfAddressContracts], bchain.AddressDescriptor(addrDesc)) - } else { - buf = buf[:0] - l := d.chainParser.PackVaruint(acs.TotalTxs, varBuf) +// 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) + 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]...) + 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]...) - l = d.chainParser.PackVaruint(acs.NonContractTxs, varBuf) + } else if ac.Standard == bchain.NonFungibleToken { + l = packVaruint(uint(len(ac.Ids)), varBuf) buf = append(buf, varBuf[:l]...) - for _, ac := range acs.Contracts { - buf = append(buf, ac.Contract...) - l = d.chainParser.PackVaruint(ac.Txs, varBuf) + 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]...) } - wb.PutCF(d.cfh[cfAddressContracts], bchain.AddressDescriptor(addrDesc), buf) } } - return nil + return buf } -// GetAddrDescContracts returns AddrContracts for given addrDesc -func (d *RocksDB) GetAddrDescContracts(addrDesc bchain.AddressDescriptor) (*AddrContracts, error) { - 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 +// 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]...) + } + } } - tt, l := d.chainParser.UnpackVaruint(buf) + return buf +} + +func unpackAddrContractsV6(buf []byte, addrDesc bchain.AddressDescriptor) (acs *AddrContracts, err error) { + tt, l := unpackVaruint(buf) buf = buf[l:] - nct, l := d.chainParser.UnpackVaruint(buf) + nct, l := unpackVaruint(buf) buf = buf[l:] - c := make([]AddrContract, 0, 4) + 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()) } - txs, l := d.chainParser.UnpackVaruint(buf[eth.EthereumTypeAddressDescriptorLen:]) contract := append(bchain.AddressDescriptor(nil), buf[:eth.EthereumTypeAddressDescriptorLen]...) - c = append(c, AddrContract{ + 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:] + 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()) + } + 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 findContractInAddressContracts(contract bchain.AddressDescriptor, contracts []AddrContract) (int, bool) { +func (d *RocksDB) storeAddressContracts(wb *grocksdb.WriteBatch, acm map[string]*AddrContracts) 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 { + buf := packAddrContracts(acs) + wb.PutCF(d.cfh[cfAddressContracts], bchain.AddressDescriptor(addrDesc), buf) + } + } + return nil +} + +// GetAddrDescContracts returns AddrContracts for given addrDesc +func (d *RocksDB) GetAddrDescContracts(addrDesc bchain.AddressDescriptor) (*AddrContracts, error) { + 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 + } + return unpackAddrContracts(buf, addrDesc) +} + +func findContractInAddressContracts(contract bchain.AddressDescriptor, contracts []unpackedAddrContract) (int, bool) { for i := range contracts { if bytes.Equal(contract, contracts[i].Contract) { return i, true @@ -102,17 +366,96 @@ func isZeroAddress(addrDesc bchain.AddressDescriptor) bool { return true } -func (d *RocksDB) addToAddressesAndContractsEthereumType(addrDesc bchain.AddressDescriptor, btxID []byte, index int32, contract bchain.AddressDescriptor, addresses bchain.AddressesMap, addressContracts map[string]*AddrContracts, addTxCount bool) error { +const transferTo = int32(0) +const transferFrom = ^int32(0) +const internalTransferTo = int32(1) +const internalTransferFrom = ^int32(1) + +// addToAddressesMapEthereumType maintains mapping between addresses and transactions in one block +// it ensures that each index is there only once, there can be for example multiple internal transactions of the same address +// the return value is true if the tx was processed before, to not to count the tx multiple times +func addToAddressesMapEthereumType(addresses addressesMap, strAddrDesc string, btxID []byte, index int32) bool { + // check that the address was already processed in this block + // if not found, it has certainly not been counted + at, found := addresses[strAddrDesc] + if found { + // if the tx is already in the slice, append the index to the array of indexes + for i, t := range at { + if bytes.Equal(btxID, t.btxID) { + for _, existing := range t.indexes { + if existing == index { + return true + } + } + at[i].indexes = append(t.indexes, index) + return true + } + } + } + addresses[strAddrDesc] = append(at, txIndexes{ + btxID: btxID, + indexes: []int32{index}, + }) + return false +} + +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) + aggregate = func(s, v *big.Int) { + s.Sub(s, v) + if s.Sign() < 0 { + // glog.Warningf("rocksdb: addToContracts: contract %s, from %s, negative aggregate", transfer.Contract, transfer.From) + s.SetUint64(0) + } + } + } else { + index = int32(contractIndex + ContractIndexOffset) + aggregate = func(s, v *big.Int) { + s.Add(s, v) + } + } + 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 { + c.Ids.insert(transfer.Value) + } + } else { // bchain.ERC1155 + for _, t := range transfer.MultiTokenValues { + c.MultiTokenValues.upsert(t, index, aggregate) + } + } + if addTxCount { + c.Txs++ + } + 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]*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++ @@ -121,29 +464,36 @@ func (d *RocksDB) addToAddressesAndContractsEthereumType(addrDesc bchain.Address } if contract == nil { if addTxCount { - ac.NonContractTxs++ + if index == internalTransferFrom || index == internalTransferTo { + ac.InternalTxs++ + } else { + ac.NonContractTxs++ + } } } else { // do not store contracts for 0x0000000000000000000000000000000000000000 address if !isZeroAddress(addrDesc) { // locate the contract and set i to the index in the array of contracts - i, found := findContractInAddressContracts(contract, ac.Contracts) + contractIndex, found := ac.findContractIndex(addrDesc, contract, d.hotAddrTracker) if !found { - i = len(ac.Contracts) - ac.Contracts = append(ac.Contracts, AddrContract{Contract: contract}) + contractIndex = len(ac.Contracts) + ac.Contracts = append(ac.Contracts, unpackedAddrContract{ + Contract: contract, + Standard: transfer.Standard, + }) + ac.addContractIndex(contract, contractIndex) } - // index 0 is for ETH transfers, contract indexes start with 1 + c := &ac.Contracts[contractIndex] + index = addToContract(c, contractIndex, index, contract, transfer, addTxCount) + } else { if index < 0 { - index = ^int32(i + 1) + index = transferFrom } else { - index = int32(i + 1) - } - if addTxCount { - ac.Contracts[i].Txs++ + index = transferTo } } } - counted := addToAddressesMap(addresses, strAddrDesc, btxID, index, bchain.BaseCoinMask, nil) + counted := addToAddressesMapEthereumType(addresses, strAddrDesc, btxID, index) if !counted { ac.TotalTxs++ } @@ -151,284 +501,878 @@ func (d *RocksDB) addToAddressesAndContractsEthereumType(addrDesc bchain.Address } type ethBlockTxContract struct { - addr, contract bchain.AddressDescriptor + from, to, contract bchain.AddressDescriptor + transferStandard bchain.TokenStandard + value big.Int + idValues []bchain.MultiTokenValue +} + +type ethInternalTransfer struct { + internalType bchain.EthereumInternalTransactionType + from, to bchain.AddressDescriptor + value big.Int +} + +type ethInternalData struct { + internalType bchain.EthereumInternalTransactionType + contract bchain.AddressDescriptor + transfers []ethInternalTransfer + errorMsg string } type ethBlockTx struct { - btxID []byte - from, to bchain.AddressDescriptor - contracts []ethBlockTxContract + btxID []byte + from, to bchain.AddressDescriptor + contracts []ethBlockTxContract + internalData *ethInternalData } -func (d *RocksDB) processAddressesEthereumType(block *bchain.Block, addresses bchain.AddressesMap, addressContracts map[string]*AddrContracts) ([]ethBlockTx, error) { - blockTxs := make([]ethBlockTx, len(block.Txs)) - for txi, tx := range block.Txs { - btxID, err := d.chainParser.PackTxid(tx.Txid) +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 + if len(tx.Vout) == 1 && len(tx.Vout[0].ScriptPubKey.Addresses) == 1 { + to, err = d.chainParser.GetAddrDescFromAddress(tx.Vout[0].ScriptPubKey.Addresses[0]) if err != nil { - return nil, err - } - blockTx := &blockTxs[txi] - blockTx.btxID = btxID - var from, to bchain.AddressDescriptor - // there is only one output address in EthereumType transaction, store it in format txid 0 - if len(tx.Vout) == 1 && len(tx.Vout[0].ScriptPubKey.Addresses) == 1 { - to, err = d.chainParser.GetAddrDescFromAddress(tx.Vout[0].ScriptPubKey.Addresses[0]) - if err != nil { - // do not log ErrAddressMissing, transactions can be without to address (for example eth contracts) - if err != bchain.ErrAddressMissing { - glog.Warningf("rocksdb: addrDesc: %v - height %d, tx %v, output", err, block.Height, tx.Txid) - } - continue + // do not log ErrAddressMissing, transactions can be without to address (for example eth contracts) + if err != bchain.ErrAddressMissing { + glog.Warningf("rocksdb: processBaseTxData: %v, tx %v, output", err, tx.Txid) } - if err = d.addToAddressesAndContractsEthereumType(to, btxID, 0, nil, addresses, addressContracts, true); err != nil { - return nil, err + } else { + if err = d.addToAddressesAndContractsEthereumType(to, blockTx.btxID, transferTo, nil, nil, true, addresses, addressContracts); err != nil { + return err } blockTx.to = to } - // there is only one input address in EthereumType transaction, store it in format txid ^0 - if len(tx.Vin) == 1 && len(tx.Vin[0].Addresses) == 1 { - from, err = d.chainParser.GetAddrDescFromAddress(tx.Vin[0].Addresses[0]) - if err != nil { - if err != bchain.ErrAddressMissing { - glog.Warningf("rocksdb: addrDesc: %v - height %d, tx %v, input", err, block.Height, tx.Txid) - } - continue - } - if err = d.addToAddressesAndContractsEthereumType(from, btxID, ^int32(0), nil, addresses, addressContracts, !bytes.Equal(from, to)); err != nil { - return nil, err - } - blockTx.from = from - } - // store erc20 transfers - erc20, err := d.chainParser.EthereumTypeGetErc20FromTx(&tx) + } + // there is only one input address in EthereumType transaction, store it in format txid ^0 + if len(tx.Vin) == 1 && len(tx.Vin[0].Addresses) == 1 { + from, err = d.chainParser.GetAddrDescFromAddress(tx.Vin[0].Addresses[0]) if err != nil { - glog.Warningf("rocksdb: GetErc20FromTx %v - height %d, tx %v", err, block.Height, tx.Txid) - } - blockTx.contracts = make([]ethBlockTxContract, len(erc20)*2) - j := 0 - for i, t := range erc20 { - var contract, from, to bchain.AddressDescriptor - contract, err = d.chainParser.GetAddrDescFromAddress(t.Contract) - if err == nil { - from, err = d.chainParser.GetAddrDescFromAddress(t.From) - if err == nil { - to, err = d.chainParser.GetAddrDescFromAddress(t.To) - } - } - if err != nil { - glog.Warningf("rocksdb: GetErc20FromTx %v - height %d, tx %v, transfer %v", err, block.Height, tx.Txid, t) - continue - } - if err = d.addToAddressesAndContractsEthereumType(to, btxID, int32(i), contract, addresses, addressContracts, true); err != nil { - return nil, err + if err != bchain.ErrAddressMissing { + glog.Warningf("rocksdb: processBaseTxData: %v, tx %v, input", err, tx.Txid) } - eq := bytes.Equal(from, to) - bc := &blockTx.contracts[j] - j++ - bc.addr = from - bc.contract = contract - if err = d.addToAddressesAndContractsEthereumType(from, btxID, ^int32(i), contract, addresses, addressContracts, !eq); err != nil { - return nil, err - } - // add to address to blockTx.contracts only if it is different from from address - if !eq { - bc = &blockTx.contracts[j] - j++ - bc.addr = to - bc.contract = contract + } else { + if err = d.addToAddressesAndContractsEthereumType(from, blockTx.btxID, transferFrom, nil, nil, !bytes.Equal(from, to), addresses, addressContracts); err != nil { + return err } + blockTx.from = from } - blockTx.contracts = blockTx.contracts[:j] } - return blockTxs, nil + return nil } -func (d *RocksDB) storeAndCleanupBlockTxsEthereumType(wb *gorocksdb.WriteBatch, block *bchain.Block, blockTxs []ethBlockTx) error { - pl := d.chainParser.PackedTxidLen() - buf := make([]byte, 0, (pl+2*eth.EthereumTypeAddressDescriptorLen)*len(blockTxs)) - varBuf := make([]byte, vlq.MaxLen64) - zeroAddress := make([]byte, eth.EthereumTypeAddressDescriptorLen) - appendAddress := func(a bchain.AddressDescriptor) { - if len(a) != eth.EthereumTypeAddressDescriptorLen { - buf = append(buf, zeroAddress...) - } else { - buf = append(buf, a...) +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 } - } - for i := range blockTxs { - blockTx := &blockTxs[i] - buf = append(buf, blockTx.btxID...) - appendAddress(blockTx.from) - appendAddress(blockTx.to) - l := d.chainParser.PackVaruint(uint(len(blockTx.contracts)), varBuf) - buf = append(buf, varBuf[:l]...) - for j := range blockTx.contracts { - c := &blockTx.contracts[j] - appendAddress(c.addr) - appendAddress(c.contract) + if len(txIndexes) > 0 { + addresses[strAddrDesc] = txIndexes } } - key := d.chainParser.PackUint(block.Height) - wb.PutCF(d.cfh[cfBlockTxs], key, buf) - return d.cleanupBlockTxs(wb, block) + return nil } -func (d *RocksDB) getBlockTxsEthereumType(height uint32) ([]ethBlockTx, error) { - pl := d.chainParser.PackedTxidLen() - val, err := d.db.GetCF(d.ro, d.cfh[cfBlockTxs], d.chainParser.PackUint(height)) - if err != nil { - return nil, err - } - defer val.Free() - buf := val.Data() - // nil data means the key was not found in DB - if buf == nil { - return nil, 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, } - // buf can be empty slice, this means the block did not contain any transactions - bt := make([]ethBlockTx, 0, 8) - getAddress := func(i int) (bchain.AddressDescriptor, int, error) { - if len(buf)-i < eth.EthereumTypeAddressDescriptorLen { - glog.Error("rocksdb: Inconsistent data in blockTxs ", hex.EncodeToString(buf)) - return nil, 0, errors.New("Inconsistent data in blockTxs") - } - a := append(bchain.AddressDescriptor(nil), buf[i:i+eth.EthereumTypeAddressDescriptorLen]...) - // return null addresses as nil - for _, b := range a { - if b != 0 { - return a, i + eth.EthereumTypeAddressDescriptorLen, nil + // index contract creation + if id.Type == bchain.CREATE { + to, err := d.chainParser.GetAddrDescFromAddress(id.Contract) + if err != nil { + if err != bchain.ErrAddressMissing { + glog.Warningf("rocksdb: processInternalData: %v, tx %v, create contract", err, tx.Txid) + } + // set the internalType to CALL if incorrect contract so that it is not breaking the packing of data to DB + 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 } } - return nil, i + eth.EthereumTypeAddressDescriptorLen, nil } - var from, to bchain.AddressDescriptor - for i := 0; i < len(buf); { - if len(buf)-i < pl { - glog.Error("rocksdb: Inconsistent data in blockTxs ", hex.EncodeToString(buf)) - return nil, errors.New("Inconsistent data in blockTxs") - } - txid := append([]byte(nil), buf[i:i+pl]...) - i += pl - from, i, err = getAddress(i) - if err != nil { - return nil, err - } - to, i, err = getAddress(i) - if err != nil { - return nil, err - } - cc, l := d.chainParser.UnpackVaruint(buf[i:]) - i += l - contracts := make([]ethBlockTxContract, cc) - for j := range contracts { - contracts[j].addr, i, err = getAddress(i) + // index internal transfers + if len(id.Transfers) > 0 { + blockTx.internalData.transfers = make([]ethInternalTransfer, len(id.Transfers)) + for i := range id.Transfers { + iti := &id.Transfers[i] + ito := &blockTx.internalData.transfers[i] + to, err := d.chainParser.GetAddrDescFromAddress(iti.To) if err != nil { - return nil, err + // do not log ErrAddressMissing, transactions can be without to address (for example eth contracts) + if err != bchain.ErrAddressMissing { + 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 + } + ito.to = to } - contracts[j].contract, i, err = getAddress(i) + from, err := d.chainParser.GetAddrDescFromAddress(iti.From) if err != nil { - return nil, err + if err != bchain.ErrAddressMissing { + 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 + } + ito.from = from } + ito.internalType = iti.Type + ito.value = iti.Value } - bt = append(bt, ethBlockTx{ - btxID: txid, - from: from, - to: to, - contracts: contracts, - }) } - return bt, nil + return nil } -func (d *RocksDB) disconnectBlockTxsEthereumType(wb *gorocksdb.WriteBatch, height uint32, blockTxs []ethBlockTx, contracts map[string]*AddrContracts) error { - glog.Info("Disconnecting block ", height, " containing ", len(blockTxs), " transactions") - addresses := make(map[string]map[string]struct{}) - disconnectAddress := func(btxID []byte, addrDesc, contract bchain.AddressDescriptor) error { - var err error - // do not process empty address - if len(addrDesc) == 0 { - return nil - } - s := string(addrDesc) - txid := string(btxID) - // find if tx for this address was already encountered - mtx, ftx := addresses[s] - if !ftx { - mtx = make(map[string]struct{}) - mtx[txid] = struct{}{} - addresses[s] = mtx - } else { - _, ftx = mtx[txid] - if !ftx { - mtx[txid] = struct{}{} +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) + } + blockTx.contracts = make([]ethBlockTxContract, len(tokenTransfers)) + for i, t := range tokenTransfers { + var contract, from, to bchain.AddressDescriptor + contract, err = d.chainParser.GetAddrDescFromAddress(t.Contract) + if err == nil { + from, err = d.chainParser.GetAddrDescFromAddress(t.From) + if err == nil { + to, err = d.chainParser.GetAddrDescFromAddress(t.To) + } + } + if err != nil { + glog.Warningf("rocksdb: processContractTransfers %v, tx %v, transfer %v", err, tx.Txid, t) + continue + } + if err = d.addToAddressesAndContractsEthereumType(to, blockTx.btxID, int32(i), contract, t, true, addresses, addressContracts); err != nil { + return err + } + eq := bytes.Equal(from, to) + if err = d.addToAddressesAndContractsEthereumType(from, blockTx.btxID, ^int32(i), contract, t, !eq, addresses, addressContracts); err != nil { + return err + } + bc := &blockTx.contracts[i] + bc.transferStandard = t.Standard + bc.from = from + bc.to = to + bc.contract = contract + bc.value = t.Value + bc.idValues = t.MultiTokenValues + } + return nil +} + +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] + btxID, err := d.chainParser.PackTxid(tx.Txid) + if err != nil { + return nil, err + } + blockTx := &blockTxs[txi] + blockTx.btxID = btxID + if err = d.processBaseTxData(blockTx, tx, addresses, addressContracts); err != nil { + return nil, err + } + // process internal data + eid, _ := tx.CoinSpecificData.(bchain.EthereumSpecificData) + if eid.InternalData != nil { + if err = d.processInternalData(blockTx, tx, eid.InternalData, addresses, addressContracts, false); err != nil { + return nil, err + } + } + // store contract transfers + if err = d.processContractTransfers(blockTx, tx, addresses, addressContracts); err != nil { + return nil, err + } + } + 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 { + if len(a) != eth.EthereumTypeAddressDescriptorLen { + buf = append(buf, ethZeroAddress...) + } else { + buf = append(buf, a...) + } + return buf +} + +func packEthInternalData(data *ethInternalData) []byte { + // allocate enough for type+contract+all transfers with bigint value + buf := make([]byte, 0, (2*len(data.transfers)+1)*(eth.EthereumTypeAddressDescriptorLen+16)) + varBuf := make([]byte, maxPackedBigintBytes) + + // internalType is one bit (CALL|CREATE), it is joined with count of internal transfers*2 + l := packVaruint(uint(data.internalType)&1+uint(len(data.transfers))<<1, varBuf) + buf = append(buf, varBuf[:l]...) + if data.internalType == bchain.CREATE { + buf = appendAddress(buf, data.contract) + } + for i := range data.transfers { + t := &data.transfers[i] + buf = append(buf, byte(t.internalType)) + buf = appendAddress(buf, t.from) + buf = appendAddress(buf, t.to) + l = packBigint(&t.value, varBuf) + buf = append(buf, varBuf[:l]...) + } + if len(data.errorMsg) > 0 { + buf = append(buf, []byte(data.errorMsg)...) + } + return buf +} + +func (d *RocksDB) unpackEthInternalData(buf []byte) (*bchain.EthereumInternalData, error) { + id := bchain.EthereumInternalData{} + v, l := unpackVaruint(buf) + id.Type = bchain.EthereumInternalTransactionType(v & 1) + id.Transfers = make([]bchain.EthereumInternalTransfer, v>>1) + if id.Type == bchain.CREATE { + addresses, _, _ := d.chainParser.GetAddressesFromAddrDesc(buf[l : l+eth.EthereumTypeAddressDescriptorLen]) + l += eth.EthereumTypeAddressDescriptorLen + if len(addresses) > 0 { + id.Contract = addresses[0] + } + } + var ll int + for i := range id.Transfers { + t := &id.Transfers[i] + t.Type = bchain.EthereumInternalTransactionType(buf[l]) + l++ + addresses, _, _ := d.chainParser.GetAddressesFromAddrDesc(buf[l : l+eth.EthereumTypeAddressDescriptorLen]) + l += eth.EthereumTypeAddressDescriptorLen + if len(addresses) > 0 { + t.From = addresses[0] + } + addresses, _, _ = d.chainParser.GetAddressesFromAddrDesc(buf[l : l+eth.EthereumTypeAddressDescriptorLen]) + l += eth.EthereumTypeAddressDescriptorLen + if len(addresses) > 0 { + t.To = addresses[0] + } + t.Value, ll = unpackBigint(buf[l:]) + l += ll + } + id.Error = eth.UnpackInternalTransactionError(buf[l:]) + return &id, nil +} + +// FourByteSignature contains 4byte signature of transaction value with parameters +// and parsed parameters (that are not stored in DB) +func packFourByteKey(fourBytes uint32, id uint32) []byte { + key := make([]byte, 0, 8) + key = append(key, packUint(fourBytes)...) + key = append(key, packUint(id)...) + return key +} + +func packFourByteSignature(signature *bchain.FourByteSignature) []byte { + buf := packString(signature.Name) + for i := range signature.Parameters { + buf = append(buf, packString(signature.Parameters[i])...) + } + return buf +} + +func unpackFourByteSignature(buf []byte) (*bchain.FourByteSignature, error) { + var signature bchain.FourByteSignature + var l int + signature.Name, l = unpackString(buf) + for l < len(buf) { + s, ll := unpackString(buf[l:]) + signature.Parameters = append(signature.Parameters, s) + l += ll + } + return &signature, nil +} + +// GetFourByteSignature gets all 4byte signature of given fourBytes and id +func (d *RocksDB) GetFourByteSignature(fourBytes uint32, id uint32) (*bchain.FourByteSignature, error) { + key := packFourByteKey(fourBytes, id) + val, err := d.db.GetCF(d.ro, d.cfh[cfFunctionSignatures], key) + if err != nil { + return nil, err + } + defer val.Free() + buf := val.Data() + if len(buf) == 0 { + return nil, nil + } + return unpackFourByteSignature(buf) +} + +var cachedByteSignatures = make(map[uint32]*[]bchain.FourByteSignature) +var cachedByteSignaturesMux sync.Mutex + +// GetFourByteSignatures gets all 4byte signatures of given fourBytes +// (there may be more than one signature starting with the same four bytes) +func (d *RocksDB) GetFourByteSignatures(fourBytes uint32) (*[]bchain.FourByteSignature, error) { + cachedByteSignaturesMux.Lock() + signatures, found := cachedByteSignatures[fourBytes] + cachedByteSignaturesMux.Unlock() + if !found { + retval := []bchain.FourByteSignature{} + key := packUint(fourBytes) + it := d.db.NewIteratorCF(d.ro, d.cfh[cfFunctionSignatures]) + defer it.Close() + for it.Seek(key); it.Valid(); it.Next() { + current := it.Key().Data() + if bytes.Compare(current[:4], key) > 0 { + break + } + val := it.Value().Data() + signature, err := unpackFourByteSignature(val) + if err != nil { + return nil, err + } + retval = append(retval, *signature) + } + cachedByteSignaturesMux.Lock() + cachedByteSignatures[fourBytes] = &retval + cachedByteSignaturesMux.Unlock() + return &retval, nil + } + return signatures, nil +} + +// StoreFourByteSignature stores 4byte signature in DB +func (d *RocksDB) StoreFourByteSignature(wb *grocksdb.WriteBatch, fourBytes uint32, id uint32, signature *bchain.FourByteSignature) error { + key := packFourByteKey(fourBytes, id) + wb.PutCF(d.cfh[cfFunctionSignatures], key, packFourByteSignature(signature)) + cachedByteSignaturesMux.Lock() + delete(cachedByteSignatures, fourBytes) + cachedByteSignaturesMux.Unlock() + return nil +} + +// GetEthereumInternalData gets transaction internal data from DB +func (d *RocksDB) GetEthereumInternalData(txid string) (*bchain.EthereumInternalData, error) { + btxID, err := d.chainParser.PackTxid(txid) + if err != nil { + return nil, err + } + return d.getEthereumInternalData(btxID) +} + +func (d *RocksDB) getEthereumInternalData(btxID []byte) (*bchain.EthereumInternalData, error) { + val, err := d.db.GetCF(d.ro, d.cfh[cfInternalData], btxID) + if err != nil { + return nil, err + } + defer val.Free() + buf := val.Data() + if len(buf) == 0 { + return nil, nil + } + return d.unpackEthInternalData(buf) +} + +func (d *RocksDB) storeInternalDataEthereumType(wb *grocksdb.WriteBatch, blockTxs []ethBlockTx) error { + for i := range blockTxs { + blockTx := &blockTxs[i] + if blockTx.internalData != nil { + wb.PutCF(d.cfh[cfInternalData], blockTx.btxID, packEthInternalData(blockTx.internalData)) + } + } + return nil +} + +func packBlockTx(buf []byte, blockTx *ethBlockTx) []byte { + varBuf := make([]byte, maxPackedBigintBytes) + buf = append(buf, blockTx.btxID...) + buf = appendAddress(buf, blockTx.from) + buf = appendAddress(buf, blockTx.to) + // internal data are not stored in blockTx, they are fetched on disconnect directly from the cfInternalData column + // contracts - store the number of address pairs + l := packVaruint(uint(len(blockTx.contracts)), varBuf) + buf = append(buf, varBuf[:l]...) + for j := range blockTx.contracts { + c := &blockTx.contracts[j] + buf = appendAddress(buf, c.from) + buf = appendAddress(buf, c.to) + buf = appendAddress(buf, c.contract) + l = packVaruint(uint(c.transferStandard), varBuf) + buf = append(buf, varBuf[:l]...) + if c.transferStandard == bchain.MultiToken { + l = packVaruint(uint(len(c.idValues)), varBuf) + buf = append(buf, varBuf[:l]...) + for i := range c.idValues { + l = packBigint(&c.idValues[i].Id, varBuf) + buf = append(buf, varBuf[:l]...) + l = packBigint(&c.idValues[i].Value, varBuf) + buf = append(buf, varBuf[:l]...) } + } else { // ERC20, ERC721 + l = packBigint(&c.value, varBuf) + buf = append(buf, varBuf[:l]...) + } + } + return buf +} + +func (d *RocksDB) storeAndCleanupBlockTxsEthereumType(wb *grocksdb.WriteBatch, block *bchain.Block, blockTxs []ethBlockTx) error { + pl := d.chainParser.PackedTxidLen() + buf := make([]byte, 0, (pl+2*eth.EthereumTypeAddressDescriptorLen)*len(blockTxs)) + for i := range blockTxs { + buf = packBlockTx(buf, &blockTxs[i]) + } + key := packUint(block.Height) + wb.PutCF(d.cfh[cfBlockTxs], key, buf) + return d.cleanupBlockTxs(wb, block) +} + +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 + } + m := []byte(message) + 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, 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 } - c, fc := contracts[s] - if !fc { - c, err = d.GetAddrDescContracts(addrDesc) + 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, 0); err != nil { return err } - contracts[s] = c } - if c != nil { - if !ftx { - c.TotalTxs-- + if len(blockSpecificData.AddressAliasRecords) > 0 { + if err := d.storeAddressAliasRecords(wb, blockSpecificData.AddressAliasRecords); err != nil { + return err } - if contract == nil { - if c.NonContractTxs > 0 { - c.NonContractTxs-- + } + for i := range blockSpecificData.Contracts { + if err := d.storeContractInfo(wb, &blockSpecificData.Contracts[i]); err != nil { + return err + } + } + } + return nil +} + +// unpackBlockTx unpacks ethBlockTx from buf, starting at position pos +// the position is updated as the data is unpacked and returned to the caller +func unpackBlockTx(buf []byte, pos int) (*ethBlockTx, int, error) { + getAddress := func(i int) (bchain.AddressDescriptor, int, error) { + if len(buf)-i < eth.EthereumTypeAddressDescriptorLen { + glog.Error("rocksdb: Inconsistent data in blockTxs ", hex.EncodeToString(buf)) + return nil, 0, errors.New("Inconsistent data in blockTxs") + } + a := append(bchain.AddressDescriptor(nil), buf[i:i+eth.EthereumTypeAddressDescriptorLen]...) + return a, i + eth.EthereumTypeAddressDescriptorLen, nil + } + var from, to bchain.AddressDescriptor + var err error + if len(buf)-pos < eth.EthereumTypeTxidLen { + glog.Error("rocksdb: Inconsistent data in blockTxs ", hex.EncodeToString(buf)) + return nil, 0, errors.New("Inconsistent data in blockTxs") + } + txid := append([]byte(nil), buf[pos:pos+eth.EthereumTypeTxidLen]...) + pos += eth.EthereumTypeTxidLen + from, pos, err = getAddress(pos) + if err != nil { + return nil, 0, err + } + to, pos, err = getAddress(pos) + if err != nil { + return nil, 0, err + } + // contracts + cc, l := unpackVaruint(buf[pos:]) + pos += l + contracts := make([]ethBlockTxContract, cc) + for j := range contracts { + c := &contracts[j] + c.from, pos, err = getAddress(pos) + if err != nil { + return nil, 0, err + } + c.to, pos, err = getAddress(pos) + if err != nil { + return nil, 0, err + } + c.contract, pos, err = getAddress(pos) + if err != nil { + return nil, 0, err + } + cc, l = unpackVaruint(buf[pos:]) + c.transferStandard = bchain.TokenStandard(cc) + pos += l + if c.transferStandard == bchain.MultiToken { + cc, l = unpackVaruint(buf[pos:]) + pos += l + c.idValues = make([]bchain.MultiTokenValue, cc) + for i := range c.idValues { + c.idValues[i].Id, l = unpackBigint(buf[pos:]) + pos += l + c.idValues[i].Value, l = unpackBigint(buf[pos:]) + pos += l + } + } else { // ERC20, ERC721 + c.value, l = unpackBigint(buf[pos:]) + pos += l + } + } + return ðBlockTx{ + btxID: txid, + from: from, + to: to, + contracts: contracts, + }, pos, nil +} + +func (d *RocksDB) getBlockTxsEthereumType(height uint32) ([]ethBlockTx, error) { + val, err := d.db.GetCF(d.ro, d.cfh[cfBlockTxs], packUint(height)) + if err != nil { + return nil, err + } + defer val.Free() + buf := val.Data() + // nil data means the key was not found in DB + if buf == nil { + return nil, nil + } + // buf can be empty slice, this means the block did not contain any transactions + bt := make([]ethBlockTx, 0, 16) + var btx *ethBlockTx + for i := 0; i < len(buf); { + btx, i, err = unpackBlockTx(buf, i) + if err != nil { + return nil, err + } + bt = append(bt, *btx) + } + 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]*unpackedAddrContracts) error { + var err error + // do not process empty address + if len(addrDesc) == 0 { + return nil + } + s := string(addrDesc) + txid := string(btxID) + // find if tx for this address was already encountered + mtx, ftx := addresses[s] + if !ftx { + mtx = make(map[string]struct{}) + mtx[txid] = struct{}{} + addresses[s] = mtx + } else { + _, ftx = mtx[txid] + if !ftx { + mtx[txid] = struct{}{} + } + } + addrContracts, fc := contracts[s] + if !fc { + addrContracts, err = d.getUnpackedAddrDescContracts(addrDesc) + if err != nil { + return err + } + if addrContracts != nil { + contracts[s] = addrContracts + } + } + if addrContracts != nil { + if !ftx { + addrContracts.TotalTxs-- + } + if btxContract == nil { + if internal { + if addrContracts.InternalTxs > 0 { + addrContracts.InternalTxs-- } else { - glog.Warning("AddressContracts ", addrDesc, ", EthTxs would be negative, tx ", hex.EncodeToString(btxID)) + glog.Warning("AddressContracts ", addrDesc, ", InternalTxs would be negative, tx ", hex.EncodeToString(btxID)) } } else { - i, found := findContractInAddressContracts(contract, c.Contracts) - if found { - if c.Contracts[i].Txs > 0 { - c.Contracts[i].Txs-- - if c.Contracts[i].Txs == 0 { - c.Contracts = append(c.Contracts[:i], c.Contracts[i+1:]...) - } + if addrContracts.NonContractTxs > 0 { + addrContracts.NonContractTxs-- + } else { + glog.Warning("AddressContracts ", addrDesc, ", EthTxs would be negative, tx ", hex.EncodeToString(btxID)) + } + } + } else { + contractIndex, found := addrContracts.findContractIndex(addrDesc, btxContract.contract, nil) + if found { + addrContract := &addrContracts.Contracts[contractIndex] + if addrContract.Txs > 0 { + addrContract.Txs-- + if addrContract.Txs == 0 { + // no transactions, remove the contract + addrContracts.Contracts = append(addrContracts.Contracts[:contractIndex], addrContracts.Contracts[contractIndex+1:]...) + addrContracts.markContractIndexDirty() } else { - glog.Warning("AddressContracts ", addrDesc, ", contract ", i, " Txs would be negative, tx ", hex.EncodeToString(btxID)) + // update the values of the contract, reverse the direction + transfer := &bchain.TokenTransfer{ + Standard: btxContract.transferStandard, + Value: btxContract.value, + MultiTokenValues: btxContract.idValues, + } + if bytes.Equal(btxContract.from, btxContract.to) { + // Self-transfers were connected through both sides while counting one tx. + addToContract(addrContract, contractIndex, transferTo, btxContract.contract, transfer, false) + addToContract(addrContract, contractIndex, transferFrom, btxContract.contract, transfer, false) + } else { + var index int32 + if bytes.Equal(addrDesc, btxContract.to) { + index = transferFrom + } else { + index = transferTo + } + addToContract(addrContract, contractIndex, index, btxContract.contract, transfer, false) + } } } else { - glog.Warning("AddressContracts ", addrDesc, ", contract ", contract, " not found, tx ", hex.EncodeToString(btxID)) + glog.Warning("AddressContracts ", addrDesc, ", contract ", contractIndex, " Txs would be negative, tx ", hex.EncodeToString(btxID)) + } + } else { + if !isZeroAddress(addrDesc) { + glog.Warning("AddressContracts ", addrDesc, ", contract ", btxContract.contract, " not found, tx ", hex.EncodeToString(btxID)) } } - } else { + } + } else { + if !isZeroAddress(addrDesc) { glog.Warning("AddressContracts ", addrDesc, " not found, tx ", hex.EncodeToString(btxID)) } - return nil } + return nil +} + +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 + } + if internalData != nil { + if internalData.Type == bchain.CREATE { + contract, err := d.chainParser.GetAddrDescFromAddress(internalData.Contract) + if err != nil { + return err + } + if err := d.disconnectAddress(btxID, true, contract, nil, addresses, contracts); err != nil { + return err + } + } + for j := range internalData.Transfers { + t := &internalData.Transfers[j] + var from, to bchain.AddressDescriptor + from, err = d.chainParser.GetAddrDescFromAddress(t.From) + if err == nil { + to, err = d.chainParser.GetAddrDescFromAddress(t.To) + } + if err != nil { + return err + } + if err := d.disconnectAddress(btxID, true, from, nil, addresses, contracts); err != nil { + return err + } + // if from==to, tx is counted only once and does not have to be disconnected again + if !bytes.Equal(from, to) { + if err := d.disconnectAddress(btxID, true, to, nil, addresses, contracts); err != nil { + return err + } + } + } + } + return nil +} + +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 { blockTx := &blockTxs[i] - if err := disconnectAddress(blockTx.btxID, blockTx.from, nil); err != nil { + if err := d.disconnectAddress(blockTx.btxID, false, blockTx.from, nil, addresses, contracts); err != nil { return err } // if from==to, tx is counted only once and does not have to be disconnected again if !bytes.Equal(blockTx.from, blockTx.to) { - if err := disconnectAddress(blockTx.btxID, blockTx.to, nil); err != nil { + if err := d.disconnectAddress(blockTx.btxID, false, blockTx.to, nil, addresses, contracts); err != nil { return err } } - for _, c := range blockTx.contracts { - if err := disconnectAddress(blockTx.btxID, c.addr, c.contract); err != nil { + // internal data + err := d.disconnectInternalData(blockTx.btxID, addresses, contracts) + if err != nil { + return err + + } + // contracts + for j := range blockTx.contracts { + c := &blockTx.contracts[j] + if err := d.disconnectAddress(blockTx.btxID, false, c.from, c, addresses, contracts); err != nil { return err } + if !bytes.Equal(c.from, c.to) { + if err := d.disconnectAddress(blockTx.btxID, false, c.to, c, addresses, contracts); err != nil { + return err + } + } } wb.DeleteCF(d.cfh[cfTransactions], blockTx.btxID) + wb.DeleteCF(d.cfh[cfInternalData], blockTx.btxID) } for a := range addresses { - key := d.chainParser.PackAddressKey([]byte(a), height) + key := packAddressKey([]byte(a), height) wb.DeleteCF(d.cfh[cfAddresses], key) } 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) @@ -441,22 +1385,544 @@ func (d *RocksDB) DisconnectBlockRangeEthereumType(lower uint32, higher uint32) } blocks[height-lower] = blockTxs } - wb := gorocksdb.NewWriteBatch() + 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 } - key := d.chainParser.PackUint(height) + key := packUint(height) wb.DeleteCF(d.cfh[cfBlockTxs], key) wb.DeleteCF(d.cfh[cfHeight], key) + wb.DeleteCF(d.cfh[cfBlockInternalDataErrors], key) + } + d.storeUnpackedAddressContracts(wb, contracts) + // Revert protocol rows whose persistHeight fell into [lower,higher]. + if err := d.disconnectErcProtocols(wb, lower, higher); err != nil { + return err } - d.storeAddressContracts(wb, contracts) - err := d.db.Write(d.wo, wb) + 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 112551e3fe..9bdf103e47 100644 --- a/db/rocksdb_ethereumtype_test.go +++ b/db/rocksdb_ethereumtype_test.go @@ -4,13 +4,17 @@ package db import ( "encoding/hex" + "fmt" + "math/big" "reflect" "testing" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/eth" - "github.com/syscoin/blockbook/tests/dbtestdata" + "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/tests/dbtestdata" ) type testEthereumParser struct { @@ -18,7 +22,229 @@ type testEthereumParser struct { } func ethereumTestnetParser() *eth.EthereumParser { - return eth.NewEthereumParser(1) + return eth.NewEthereumParser(1, true) +} + +func bigintFromStringToHex(s string) string { + var b big.Int + b.SetString(s, 0) + 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) { @@ -34,10 +260,11 @@ func verifyAfterEthereumTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect boo } } if err := checkColumn(d, cfAddresses, []keyPair{ - {addressKeyHex(dbtestdata.EthAddr3e, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T1, []int32{^0}, d), nil}, - {addressKeyHex(dbtestdata.EthAddr55, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{1}, d) + txIndexesHex(dbtestdata.EthTxidB1T1, []int32{0}, d), nil}, - {addressKeyHex(dbtestdata.EthAddr20, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{^0, ^1}, d), nil}, - {addressKeyHex(dbtestdata.EthAddrContract4a, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{0}, d), nil}, + {addressKeyHex(dbtestdata.EthAddr3e, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{^1, 1}) + txIndexesHex(dbtestdata.EthTxidB1T1, []int32{^0}), nil}, + {addressKeyHex(dbtestdata.EthAddr55, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{2}) + txIndexesHex(dbtestdata.EthTxidB1T1, []int32{0}), nil}, + {addressKeyHex(dbtestdata.EthAddr20, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{^0, ^2}), nil}, + {addressKeyHex(dbtestdata.EthAddr9f, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{^1, 1}), nil}, + {addressKeyHex(dbtestdata.EthAddrContract4a, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{0, 1}), nil}, }); err != nil { { t.Fatal(err) @@ -45,10 +272,51 @@ func verifyAfterEthereumTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect boo } if err := checkColumn(d, cfAddressContracts, []keyPair{ - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr3e, d.chainParser), "0101", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser), "0201" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + "01", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr20, d.chainParser), "0101" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + "01", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser), "0101", nil}, + {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr3e, d.chainParser), "02010200", nil}, + { + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser), + "02010001" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)), nil, + }, + { + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr20, d.chainParser), + "01010001" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)), nil, + }, + {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr9f, d.chainParser), "01000200", nil}, + {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser), "01010100", nil}, + }); err != nil { + { + t.Fatal(err) + } + } + + var destructedInBlock uint + if afterDisconnect { + destructedInBlock = 44445 + } + if err := checkColumn(d, cfContracts, []keyPair{ + { + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser), + "0b436f6e7472616374203734" + // Contract 74 + "03533734" + // S74 + "054552433230" + // ERC20 + varuintToHex(12) + varuintToHex(44444) + varuintToHex(destructedInBlock), + nil, + }, + }); err != nil { + { + t.Fatal(err) + } + } + + if err := checkColumn(d, cfInternalData, []keyPair{ + { + dbtestdata.EthTxidB1T2, + "06" + + "01" + dbtestdata.EthAddr9f + dbtestdata.EthAddrContract4a + "030f4240" + + "00" + dbtestdata.EthAddr3e + dbtestdata.EthAddr9f + "030f4241" + + "00" + dbtestdata.EthAddr3e + dbtestdata.EthAddr3e + "030f4242", + nil, + }, }); err != nil { { t.Fatal(err) @@ -66,9 +334,7 @@ func verifyAfterEthereumTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect boo dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr3e, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + "00" + dbtestdata.EthTxidB1T2 + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr20, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + - "02" + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr20, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser), + "01" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr20, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(uint(bchain.FungibleToken)) + bigintFromStringToHex("10000000000000000000000"), nil, }, } @@ -80,7 +346,7 @@ func verifyAfterEthereumTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect boo } } -func verifyAfterEthereumTypeBlock2(t *testing.T, d *RocksDB) { +func verifyAfterEthereumTypeBlock2(t *testing.T, d *RocksDB, wantBlockInternalDataError bool) { if err := checkColumn(d, cfHeight, []keyPair{ { "0041eee8", @@ -89,7 +355,7 @@ func verifyAfterEthereumTypeBlock2(t *testing.T, d *RocksDB) { }, { "0041eee9", - "2b57e15e93a0ed197417a34c2498b7187df79099572c04a6b6e6ff418f74e6ee" + uintToHex(1534859988) + varuintToHex(2) + varuintToHex(2345678), + "2b57e15e93a0ed197417a34c2498b7187df79099572c04a6b6e6ff418f74e6ee" + uintToHex(1534859988) + varuintToHex(6) + varuintToHex(2345678), nil, }, }); err != nil { @@ -98,15 +364,27 @@ func verifyAfterEthereumTypeBlock2(t *testing.T, d *RocksDB) { } } if err := checkColumn(d, cfAddresses, []keyPair{ - {addressKeyHex(dbtestdata.EthAddr3e, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T1, []int32{^0}, d), nil}, - {addressKeyHex(dbtestdata.EthAddr55, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{1}, d) + txIndexesHex(dbtestdata.EthTxidB1T1, []int32{0}, d), nil}, - {addressKeyHex(dbtestdata.EthAddr20, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{^0, ^1}, d), nil}, - {addressKeyHex(dbtestdata.EthAddrContract4a, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{0}, d), nil}, - {addressKeyHex(dbtestdata.EthAddr55, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T2, []int32{^2, 1}, d) + txIndexesHex(dbtestdata.EthTxidB2T1, []int32{^0}, d), nil}, - {addressKeyHex(dbtestdata.EthAddr9f, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T1, []int32{0}, d), nil}, - {addressKeyHex(dbtestdata.EthAddr4b, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T2, []int32{^0, 1, ^2, 2, ^1}, d), nil}, - {addressKeyHex(dbtestdata.EthAddr7b, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T2, []int32{^1, 2}, d), nil}, - {addressKeyHex(dbtestdata.EthAddrContract47, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T2, []int32{0}, d), nil}, + {addressKeyHex(dbtestdata.EthAddr3e, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{^1, 1}) + txIndexesHex(dbtestdata.EthTxidB1T1, []int32{^0}), nil}, + {addressKeyHex(dbtestdata.EthAddr55, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{2}) + txIndexesHex(dbtestdata.EthTxidB1T1, []int32{0}), nil}, + {addressKeyHex(dbtestdata.EthAddr20, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{^0, ^2}), nil}, + {addressKeyHex(dbtestdata.EthAddr9f, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{^1, 1}), nil}, + {addressKeyHex(dbtestdata.EthAddrContract4a, 4321000, d), txIndexesHex(dbtestdata.EthTxidB1T2, []int32{0, 1}), nil}, + + {addressKeyHex(dbtestdata.EthAddrZero, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T5, []int32{transferFrom}), nil}, + {addressKeyHex(dbtestdata.EthAddr3e, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T4, []int32{^0, 2}), nil}, + {addressKeyHex(dbtestdata.EthAddr4b, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T2, []int32{^0, ^1, 2, ^3, 3, ^2}), nil}, + {addressKeyHex(dbtestdata.EthAddr55, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T6, []int32{0, ^0, 4, ^4}) + txIndexesHex(dbtestdata.EthTxidB2T2, []int32{^3, 2}) + txIndexesHex(dbtestdata.EthTxidB2T1, []int32{^0}), nil}, + {addressKeyHex(dbtestdata.EthAddr5d, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T5, []int32{^0, 2}), nil}, + {addressKeyHex(dbtestdata.EthAddr7b, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T3, []int32{4}) + txIndexesHex(dbtestdata.EthTxidB2T2, []int32{^2, 3}), nil}, + {addressKeyHex(dbtestdata.EthAddr83, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T3, []int32{^0, ^2}), nil}, + {addressKeyHex(dbtestdata.EthAddr92, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T4, []int32{0}), nil}, + {addressKeyHex(dbtestdata.EthAddr9f, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T2, []int32{1}) + txIndexesHex(dbtestdata.EthTxidB2T1, []int32{0}), nil}, + {addressKeyHex(dbtestdata.EthAddrA3, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T4, []int32{^2}), nil}, + {addressKeyHex(dbtestdata.EthAddrContract0d, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T2, []int32{1}), nil}, + {addressKeyHex(dbtestdata.EthAddrContract47, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T2, []int32{0}), nil}, + {addressKeyHex(dbtestdata.EthAddrContract4a, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T2, []int32{^1}), nil}, + {addressKeyHex(dbtestdata.EthAddrContract6f, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T5, []int32{0}), nil}, + {addressKeyHex(dbtestdata.EthAddrContractCd, 4321001, d), txIndexesHex(dbtestdata.EthTxidB2T3, []int32{0}), nil}, }); err != nil { { t.Fatal(err) @@ -114,14 +392,95 @@ func verifyAfterEthereumTypeBlock2(t *testing.T, d *RocksDB) { } if err := checkColumn(d, cfAddressContracts, []keyPair{ - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr3e, d.chainParser), "0101", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser), "0402" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + "02" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + "01", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr20, d.chainParser), "0101" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + "01", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser), "0101", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr9f, d.chainParser), "0101", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr4b, d.chainParser), "0101" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + "02" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + "02", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr7b, d.chainParser), "0100" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + "01" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + "01", nil}, - {dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract47, d.chainParser), "0101", nil}, + { + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr20, d.chainParser), + "01010001" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(1<<2+uint(bchain.FungibleToken)) + bigintToHex(big.NewInt(0)), nil, + }, + { + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr3e, d.chainParser), + "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), + "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), + "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), + "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), + "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), + "01010001" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContractCd, d.chainParser) + varuintToHex(1<<2+uint(bchain.NonFungibleToken)) + varuintToHex(0), nil, + }, + { + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrA3, d.chainParser), + "01000001" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract6f, d.chainParser) + varuintToHex(1<<2+uint(bchain.MultiToken)) + varuintToHex(0), 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) + } + } + + if err := checkColumn(d, cfContracts, []keyPair{ + { + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser), + "0b436f6e7472616374203734" + // Contract 74 + "03533734" + // S74 + "054552433230" + // ERC20 + varuintToHex(12) + varuintToHex(44444) + varuintToHex(44445), + nil, + }, + }); err != nil { + { + t.Fatal(err) + } + } + + if err := checkColumn(d, cfInternalData, []keyPair{ + { + dbtestdata.EthTxidB1T2, + "06" + + "01" + dbtestdata.EthAddr9f + dbtestdata.EthAddrContract4a + "030f4240" + + "00" + dbtestdata.EthAddr3e + dbtestdata.EthAddr9f + "030f4241" + + "00" + dbtestdata.EthAddr3e + dbtestdata.EthAddr3e + "030f4242", + nil, + }, + { + dbtestdata.EthTxidB2T1, + "00" + hex.EncodeToString([]byte(dbtestdata.EthTx3InternalData.Error)), + nil, + }, + { + dbtestdata.EthTxidB2T2, + "05" + dbtestdata.EthAddrContract0d + + "00" + dbtestdata.EthAddr4b + dbtestdata.EthAddr9f + "030f424a" + + "02" + dbtestdata.EthAddrContract4a + dbtestdata.EthAddr9f + "030f424b", + nil, + }, }); err != nil { { t.Fatal(err) @@ -135,15 +494,22 @@ func verifyAfterEthereumTypeBlock2(t *testing.T, d *RocksDB) { dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr9f, d.chainParser) + "00" + dbtestdata.EthTxidB2T2 + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr4b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract47, d.chainParser) + - "08" + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr4b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr4b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr7b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr4b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr4b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + - dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr7b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser), + "04" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr4b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + varuintToHex(uint(bchain.FungibleToken)) + bigintFromStringToHex("7675000000000000001") + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr4b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(uint(bchain.FungibleToken)) + bigintFromStringToHex("854307892726464") + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr7b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr4b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract4a, d.chainParser) + varuintToHex(uint(bchain.FungibleToken)) + bigintFromStringToHex("871180000950184") + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr4b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr7b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract0d, d.chainParser) + varuintToHex(uint(bchain.FungibleToken)) + bigintFromStringToHex("7674999999999991915") + + dbtestdata.EthTxidB2T3 + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr83, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContractCd, d.chainParser) + + "01" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr83, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr7b, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContractCd, d.chainParser) + varuintToHex(uint(bchain.NonFungibleToken)) + bigintFromStringToHex("1") + + dbtestdata.EthTxidB2T4 + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr3e, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr92, d.chainParser) + + "01" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrA3, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr3e, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract6f, d.chainParser) + varuintToHex(uint(bchain.MultiToken)) + "01" + bigintFromStringToHex("150") + bigintFromStringToHex("1") + + dbtestdata.EthTxidB2T5 + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr5d, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract6f, d.chainParser) + + "01" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrZero, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr5d, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddrContract6f, d.chainParser) + varuintToHex(uint(bchain.MultiToken)) + "02" + bigintFromStringToHex("1776") + bigintFromStringToHex("1") + bigintFromStringToHex("1898") + bigintFromStringToHex("10") + + dbtestdata.EthTxidB2T6 + + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + + "01" + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + dbtestdata.AddressToPubKeyHex(dbtestdata.EthAddr55, d.chainParser) + varuintToHex(uint(bchain.FungibleToken)) + bigintFromStringToHex("10000000000000000000000"), nil, }, }); err != nil { @@ -151,6 +517,87 @@ func verifyAfterEthereumTypeBlock2(t *testing.T, d *RocksDB) { t.Fatal(err) } } + + var addressAliases []keyPair + addressAliases = []keyPair{ + { + hex.EncodeToString([]byte(dbtestdata.EthAddr7bEIP55)), + hex.EncodeToString([]byte("address7b")), + nil, + }, + { + hex.EncodeToString([]byte(dbtestdata.EthAddr20EIP55)), + hex.EncodeToString([]byte("address20")), + nil, + }, + } + if err := checkColumn(d, cfAddressAliases, addressAliases); err != nil { + { + t.Fatal(err) + } + } + + var internalDataError []keyPair + if wantBlockInternalDataError { + internalDataError = []keyPair{ + { + "0041eee9", + "2b57e15e93a0ed197417a34c2498b7187df79099572c04a6b6e6ff418f74e6ee" + "00" + hex.EncodeToString([]byte("test error")), + nil, + }, + } + } + if err := checkColumn(d, cfBlockInternalDataErrors, internalDataError); err != nil { + { + t.Fatal(err) + } + } + +} + +func formatInternalData(in *bchain.EthereumInternalData) *bchain.EthereumInternalData { + out := *in + if out.Type == bchain.CREATE { + out.Contract = eth.EIP55AddressFromAddress(out.Contract) + } + for i := range out.Transfers { + t := &out.Transfers[i] + t.From = eth.EIP55AddressFromAddress(t.From) + t.To = eth.EIP55AddressFromAddress(t.To) + } + out.Error = eth.UnpackInternalTransactionError([]byte(in.Error)) + return &out +} + +func testFourByteSignature(t *testing.T, d *RocksDB) { + fourBytes := uint32(1234123) + id := uint32(42313) + signature := bchain.FourByteSignature{ + Name: "xyz", + Parameters: []string{"address", "(bytes,uint256[],uint256)", "uint16"}, + } + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + if err := d.StoreFourByteSignature(wb, fourBytes, id, &signature); err != nil { + t.Fatal(err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatal(err) + } + got, err := d.GetFourByteSignature(fourBytes, id) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(*got, signature) { + t.Errorf("testFourByteSignature: got %+v, want %+v", got, signature) + } + gotSlice, err := d.GetFourByteSignatures(fourBytes) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(*gotSlice, []bchain.FourByteSignature{signature}) { + t.Errorf("testFourByteSignature: got %+v, want %+v", *gotSlice, []bchain.FourByteSignature{signature}) + } } // TestRocksDB_Index_EthereumType is an integration test probing the whole indexing functionality for EthereumType chains @@ -179,31 +626,53 @@ 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 + // connect 2nd block, simulate InternalDataError and AddressAlias block2 := dbtestdata.GetTestEthereumTypeBlock2(d.chainParser) if err := d.ConnectBlock(block2); err != nil { t.Fatal(err) } - verifyAfterEthereumTypeBlock2(t, d) + 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 verifyGetTransactions(t, d, "0x"+dbtestdata.EthAddr55, 0, 10000000, []txidIndex{ - {"0x" + dbtestdata.EthTxidB2T2, ^2}, - {"0x" + dbtestdata.EthTxidB2T2, 1}, + {"0x" + dbtestdata.EthTxidB2T6, 0}, + {"0x" + dbtestdata.EthTxidB2T6, ^0}, + {"0x" + dbtestdata.EthTxidB2T6, 4}, + {"0x" + dbtestdata.EthTxidB2T6, ^4}, + {"0x" + dbtestdata.EthTxidB2T2, ^3}, + {"0x" + dbtestdata.EthTxidB2T2, 2}, {"0x" + dbtestdata.EthTxidB2T1, ^0}, - {"0x" + dbtestdata.EthTxidB1T2, 1}, + {"0x" + dbtestdata.EthTxidB1T2, 2}, {"0x" + dbtestdata.EthTxidB1T1, 0}, }, nil) verifyGetTransactions(t, d, "mtGXQvBowMkBpnhLckhxhbwYK44Gs9eBad", 500000, 1000000, []txidIndex{}, errors.New("Address missing")) + id, err := d.GetEthereumInternalData(dbtestdata.EthTxidB1T1) + if err != nil || id != nil { + t.Errorf("GetEthereumInternalData(%s) = %+v, want %+v, err %v", dbtestdata.EthTxidB1T1, id, nil, err) + } + id, err = d.GetEthereumInternalData(dbtestdata.EthTxidB1T2) + if err != nil || !reflect.DeepEqual(id, formatInternalData(dbtestdata.EthTx2InternalData)) { + t.Errorf("GetEthereumInternalData(%s) = %+v, want %+v, err %v", dbtestdata.EthTxidB1T2, id, formatInternalData(dbtestdata.EthTx2InternalData), err) + } + id, err = d.GetEthereumInternalData(dbtestdata.EthTxidB2T1) + if err != nil || !reflect.DeepEqual(id, formatInternalData(dbtestdata.EthTx3InternalData)) { + t.Errorf("GetEthereumInternalData(%s) = %+v, want %+v, err %v", dbtestdata.EthTxidB2T1, id, formatInternalData(dbtestdata.EthTx3InternalData), err) + } + id, err = d.GetEthereumInternalData(dbtestdata.EthTxidB2T2) + if err != nil || !reflect.DeepEqual(id, formatInternalData(dbtestdata.EthTx4InternalData)) { + t.Errorf("GetEthereumInternalData(%s) = %+v, want %+v, err %v", dbtestdata.EthTxidB2T2, id, formatInternalData(dbtestdata.EthTx4InternalData), err) + } + // GetBestBlock height, hash, err := d.GetBestBlock() if err != nil { @@ -239,9 +708,9 @@ func TestRocksDB_Index_EthereumType(t *testing.T) { if err != nil { t.Fatal(err) } - iw := &bchain.DbBlockInfo{ + iw := &BlockInfo{ Hash: "0x2b57e15e93a0ed197417a34c2498b7187df79099572c04a6b6e6ff418f74e6ee", - Txs: 2, + Txs: 6, Size: 2345678, Time: 1534859988, Height: 4321001, @@ -250,9 +719,20 @@ func TestRocksDB_Index_EthereumType(t *testing.T) { t.Errorf("GetBlockInfo() = %+v, want %+v", info, iw) } + // Test to store and get FourByteSignature + testFourByteSignature(t, d) + // Test tx caching functionality, leave one tx in db to test cleanup in DisconnectBlock testTxCache(t, d, block1, &block1.Txs[0]) + // InternalData are not packed and stored in DB, remove them so that the test does not fail + esd, _ := block2.Txs[0].CoinSpecificData.(bchain.EthereumSpecificData) + eid := esd.InternalData + esd.InternalData = nil + block2.Txs[0].CoinSpecificData = esd testTxCache(t, d, block2, &block2.Txs[0]) + // restore InternalData + esd.InternalData = eid + block2.Txs[0].CoinSpecificData = esd if err = d.PutTx(&block2.Txs[1], block2.Height, block2.Txs[1].Blocktime); err != nil { t.Fatal(err) } @@ -273,7 +753,7 @@ func TestRocksDB_Index_EthereumType(t *testing.T) { if err == nil || err.Error() != "Cannot disconnect blocks with height 4321000 and lower. It is necessary to rebuild index." { t.Fatal(err) } - verifyAfterEthereumTypeBlock2(t, d) + verifyAfterEthereumTypeBlock2(t, d, true) // disconnect the 2nd block, verify that the db contains only data from the 1st block with restored unspentTxs // and that the cached tx is removed @@ -288,18 +768,1099 @@ 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 if err := d.ConnectBlock(block2); err != nil { t.Fatal(err) } - verifyAfterEthereumTypeBlock2(t, d) + verifyAfterEthereumTypeBlock2(t, d, false) + + if len(d.is.BlockTimes) != 4321002 { + t.Fatal("Expecting is.BlockTimes 4321002, got ", len(d.is.BlockTimes)) + } + +} + +func Test_BulkConnect_EthereumType(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{ + 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") + } + + if len(d.is.BlockTimes) != 0 { + t.Fatal("Expecting is.BlockTimes 0, got ", len(d.is.BlockTimes)) + } + + if err := bc.ConnectBlock(dbtestdata.GetTestEthereumTypeBlock1(d.chainParser), false); err != nil { + t.Fatal(err) + } + if err := checkColumn(d, cfBlockTxs, []keyPair{}); err != nil { + { + t.Fatal(err) + } + } + + // connect 2nd block, simulate InternalDataError + block2 := dbtestdata.GetTestEthereumTypeBlock2(d.chainParser) + if err := bc.ConnectBlock(block2, true); err != nil { + t.Fatal(err) + } + block2.CoinSpecificData = nil + + 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") + } + + verifyAfterEthereumTypeBlock2(t, d, true) - 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)) } +} +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} + tests := []struct { + name string + data ethInternalData + want *bchain.EthereumInternalData + }{ + { + name: "CALL 1", + data: ethInternalData{ + internalType: bchain.CALL, + transfers: []ethInternalTransfer{ + { + internalType: bchain.CALL, + from: addressToAddrDesc(dbtestdata.EthAddr3e, parser), + to: addressToAddrDesc(dbtestdata.EthAddr20, parser), + value: *big.NewInt(412342134), + }, + }, + }, + want: &bchain.EthereumInternalData{ + Type: bchain.CALL, + Transfers: []bchain.EthereumInternalTransfer{ + { + Type: bchain.CALL, + From: eth.EIP55AddressFromAddress(dbtestdata.EthAddr3e), + To: eth.EIP55AddressFromAddress(dbtestdata.EthAddr20), + Value: *big.NewInt(412342134), + }, + }, + }, + }, + { + name: "CALL 2", + data: ethInternalData{ + internalType: bchain.CALL, + errorMsg: "error error error", + transfers: []ethInternalTransfer{ + { + internalType: bchain.CALL, + from: addressToAddrDesc(dbtestdata.EthAddr3e, parser), + to: addressToAddrDesc(dbtestdata.EthAddr20, parser), + value: *big.NewInt(4123421341), + }, + { + internalType: bchain.CREATE, + from: addressToAddrDesc(dbtestdata.EthAddr4b, parser), + to: addressToAddrDesc(dbtestdata.EthAddr55, parser), + value: *big.NewInt(123), + }, + { + internalType: bchain.SELFDESTRUCT, + from: addressToAddrDesc(dbtestdata.EthAddr7b, parser), + to: addressToAddrDesc(dbtestdata.EthAddr83, parser), + value: *big.NewInt(67890), + }, + }, + }, + want: &bchain.EthereumInternalData{ + Type: bchain.CALL, + Error: "error error error", + Transfers: []bchain.EthereumInternalTransfer{ + { + Type: bchain.CALL, + From: eth.EIP55AddressFromAddress(dbtestdata.EthAddr3e), + To: eth.EIP55AddressFromAddress(dbtestdata.EthAddr20), + Value: *big.NewInt(4123421341), + }, + { + Type: bchain.CREATE, + From: eth.EIP55AddressFromAddress(dbtestdata.EthAddr4b), + To: eth.EIP55AddressFromAddress(dbtestdata.EthAddr55), + Value: *big.NewInt(123), + }, + { + Type: bchain.SELFDESTRUCT, + From: eth.EIP55AddressFromAddress(dbtestdata.EthAddr7b), + To: eth.EIP55AddressFromAddress(dbtestdata.EthAddr83), + Value: *big.NewInt(67890), + }, + }, + }, + }, + { + name: "CREATE", + data: ethInternalData{ + internalType: bchain.CREATE, + contract: addressToAddrDesc(dbtestdata.EthAddrContract0d, parser), + }, + want: &bchain.EthereumInternalData{ + Type: bchain.CREATE, + Contract: eth.EIP55AddressFromAddress(dbtestdata.EthAddrContract0d), + Transfers: []bchain.EthereumInternalTransfer{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + packed := packEthInternalData(&tt.data) + got, err := db.unpackEthInternalData(packed) + if err != nil { + t.Errorf("unpackEthInternalData() error = %v", err) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("packEthInternalData/unpackEthInternalData = %+v, want %+v", got, tt.want) + } + }) + } +} + +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 { + buf []byte + addrDesc bchain.AddressDescriptor + } + tests := []struct { + name string + data AddrContracts + }{ + { + name: "1", + data: AddrContracts{ + TotalTxs: 30, + NonContractTxs: 20, + InternalTxs: 10, + Contracts: []AddrContract{}, + }, + }, + { + name: "2", + data: AddrContracts{ + TotalTxs: 12345, + NonContractTxs: 444, + InternalTxs: 8873, + Contracts: []AddrContract{ + { + Standard: bchain.FungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract0d, parser), + Txs: 8, + Value: *big.NewInt(793201132), + }, + { + Standard: bchain.NonFungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), + Txs: 41235, + Ids: Ids{ + *big.NewInt(1), + *big.NewInt(2), + *big.NewInt(3), + *big.NewInt(3144223412344123), + *big.NewInt(5), + }, + }, + { + Standard: bchain.MultiToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract4a, parser), + Txs: 64, + MultiTokenValues: MultiTokenValues{ + { + Id: *big.NewInt(1), + Value: *big.NewInt(1412341234), + }, + { + Id: *big.NewInt(123412341234), + Value: *big.NewInt(3), + }, + }, + }, + }, + }, + }, + { + 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) { + packed := packAddrContracts(&tt.data) + got, err := unpackAddrContracts(packed, nil) + if err != nil { + t.Errorf("unpackAddrContracts() error = %v", err) + return + } + if !reflect.DeepEqual(got, &tt.data) { + t.Errorf("unpackAddrContracts() = %v, want %v", got, tt.data) + } + }) + } +} + +func Test_addToContracts(t *testing.T) { + // the test builds addToContracts that keeps contracts of an address + // the test adds and removes values from addToContracts, therefore the order of tests is important + addrContracts := &AddrContracts{} + parser := ethereumTestnetParser() + type args struct { + index int32 + contract bchain.AddressDescriptor + transfer *bchain.TokenTransfer + addTxCount bool + } + tests := []struct { + name string + args args + wantIndex int32 + wantAddrContracts *AddrContracts + }{ + { + name: "ERC20 to", + args: args{ + index: 1, + contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), + transfer: &bchain.TokenTransfer{ + Standard: bchain.FungibleToken, + Value: *big.NewInt(123456), + }, + addTxCount: true, + }, + wantIndex: 0 + ContractIndexOffset, // the first contract of the address + wantAddrContracts: &AddrContracts{ + Contracts: []AddrContract{ + { + Standard: bchain.FungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), + Txs: 1, + Value: *big.NewInt(0), + }, + }, + }, + }, + { + name: "ERC20 from", + args: args{ + index: ^1, + contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), + transfer: &bchain.TokenTransfer{ + Standard: bchain.FungibleToken, + Value: *big.NewInt(23456), + }, + addTxCount: true, + }, + wantIndex: ^(0 + ContractIndexOffset), // the first contract of the address + wantAddrContracts: &AddrContracts{ + Contracts: []AddrContract{ + { + Standard: bchain.FungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), + Value: *big.NewInt(0), + Txs: 2, + }, + }, + }, + }, + { + name: "ERC721 to id 1", + args: args{ + index: 1, + contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), + transfer: &bchain.TokenTransfer{ + Standard: bchain.NonFungibleToken, + Value: *big.NewInt(1), + }, + addTxCount: true, + }, + wantIndex: 1 + ContractIndexOffset, // the 2nd contract of the address + wantAddrContracts: &AddrContracts{ + Contracts: []AddrContract{ + { + Standard: bchain.FungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), + Value: *big.NewInt(0), + Txs: 2, + }, + { + Standard: bchain.NonFungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), + Txs: 1, + Ids: Ids{*big.NewInt(1)}, + }, + }, + }, + }, + { + name: "ERC721 to id 2", + args: args{ + index: 1, + contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), + transfer: &bchain.TokenTransfer{ + Standard: bchain.NonFungibleToken, + Value: *big.NewInt(2), + }, + addTxCount: true, + }, + wantIndex: 1 + ContractIndexOffset, // the 2nd contract of the address + wantAddrContracts: &AddrContracts{ + Contracts: []AddrContract{ + { + Standard: bchain.FungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), + Value: *big.NewInt(0), + Txs: 2, + }, + { + Standard: bchain.NonFungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), + Txs: 2, + Ids: Ids{*big.NewInt(1), *big.NewInt(2)}, + }, + }, + }, + }, + { + name: "ERC721 from id 1, addTxCount=false", + args: args{ + index: ^1, + contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), + transfer: &bchain.TokenTransfer{ + Standard: bchain.NonFungibleToken, + Value: *big.NewInt(1), + }, + addTxCount: false, + }, + wantIndex: ^(1 + ContractIndexOffset), // the 2nd contract of the address + wantAddrContracts: &AddrContracts{ + Contracts: []AddrContract{ + { + Standard: bchain.FungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), + Value: *big.NewInt(0), + Txs: 2, + }, + { + Standard: bchain.NonFungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), + Txs: 2, + Ids: Ids{*big.NewInt(2)}, + }, + }, + }, + }, + { + name: "ERC1155 to id 11, value 56789", + args: args{ + index: 1, + contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), + transfer: &bchain.TokenTransfer{ + Standard: bchain.MultiToken, + MultiTokenValues: []bchain.MultiTokenValue{ + { + Id: *big.NewInt(11), + Value: *big.NewInt(56789), + }, + }, + }, + addTxCount: true, + }, + wantIndex: 2 + ContractIndexOffset, // the 3nd contract of the address + wantAddrContracts: &AddrContracts{ + Contracts: []AddrContract{ + { + Standard: bchain.FungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), + Value: *big.NewInt(0), + Txs: 2, + }, + { + Standard: bchain.NonFungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), + Txs: 2, + Ids: Ids{*big.NewInt(2)}, + }, + { + Standard: bchain.MultiToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), + Txs: 1, + MultiTokenValues: MultiTokenValues{ + { + Id: *big.NewInt(11), + Value: *big.NewInt(56789), + }, + }, + }, + }, + }, + }, + { + name: "ERC1155 to id 11, value 111 and id 22, value 222", + args: args{ + index: 1, + contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), + transfer: &bchain.TokenTransfer{ + Standard: bchain.MultiToken, + MultiTokenValues: []bchain.MultiTokenValue{ + { + Id: *big.NewInt(11), + Value: *big.NewInt(111), + }, + { + Id: *big.NewInt(22), + Value: *big.NewInt(222), + }, + }, + }, + addTxCount: true, + }, + wantIndex: 2 + ContractIndexOffset, // the 3nd contract of the address + wantAddrContracts: &AddrContracts{ + Contracts: []AddrContract{ + { + Standard: bchain.FungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), + Value: *big.NewInt(0), + Txs: 2, + }, + { + Standard: bchain.NonFungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), + Txs: 2, + Ids: Ids{*big.NewInt(2)}, + }, + { + Standard: bchain.MultiToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), + Txs: 2, + MultiTokenValues: MultiTokenValues{ + { + Id: *big.NewInt(11), + Value: *big.NewInt(56900), + }, + { + Id: *big.NewInt(22), + Value: *big.NewInt(222), + }, + }, + }, + }, + }, + }, + { + name: "ERC1155 from id 11, value 112 and id 22, value 222", + args: args{ + index: ^1, + contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), + transfer: &bchain.TokenTransfer{ + Standard: bchain.MultiToken, + MultiTokenValues: []bchain.MultiTokenValue{ + { + Id: *big.NewInt(11), + Value: *big.NewInt(112), + }, + { + Id: *big.NewInt(22), + Value: *big.NewInt(222), + }, + }, + }, + addTxCount: true, + }, + wantIndex: ^(2 + ContractIndexOffset), // the 3nd contract of the address + wantAddrContracts: &AddrContracts{ + Contracts: []AddrContract{ + { + Standard: bchain.FungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract47, parser), + Value: *big.NewInt(0), + Txs: 2, + }, + { + Standard: bchain.NonFungibleToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContract6f, parser), + Txs: 2, + Ids: Ids{*big.NewInt(2)}, + }, + { + Standard: bchain.MultiToken, + Contract: addressToAddrDesc(dbtestdata.EthAddrContractCd, parser), + Txs: 3, + MultiTokenValues: MultiTokenValues{ + { + Id: *big.NewInt(11), + Value: *big.NewInt(56788), + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 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(unpackedAddrContracts.Contracts) + unpackedAddrContracts.Contracts = append(unpackedAddrContracts.Contracts, unpackedAddrContract{ + Contract: tt.args.contract, + Standard: tt.args.transfer.Standard, + }) + } + 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) + } + }) + } +} + +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_ERC721_SelfTransfer_ReorgTokenLoss(t *testing.T) { + addrDesc := makeTestAddrDesc(0x7101) + contract := makeTestAddrDesc(0x7102) + acs := &unpackedAddrContracts{ + TotalTxs: 6, + Contracts: []unpackedAddrContract{ + { + Standard: bchain.NonFungibleToken, + Contract: contract, + Txs: 6, + Ids: unpackedIds{ + {Value: big.NewInt(1)}, + {Value: big.NewInt(2)}, + {Value: big.NewInt(3)}, + }, + }, + }, + } + btxContract := ðBlockTxContract{ + from: addrDesc, + to: addrDesc, + contract: contract, + transferStandard: bchain.NonFungibleToken, + value: *big.NewInt(2), + } + + err := (&RocksDB{}).disconnectAddress([]byte{0x01}, false, addrDesc, btxContract, map[string]map[string]struct{}{}, map[string]*unpackedAddrContracts{ + string(addrDesc): acs, + }) + if err != nil { + t.Fatal(err) + } + if got, want := acs.TotalTxs, uint(5); got != want { + t.Fatalf("TotalTxs = %d, want %d", got, want) + } + if got, want := acs.Contracts[0].Txs, uint(5); got != want { + t.Fatalf("contract Txs = %d, want %d", got, want) + } + + want := []*big.Int{big.NewInt(1), big.NewInt(2), big.NewInt(3)} + if got := acs.Contracts[0].Ids; len(got) != len(want) { + t.Fatalf("Ids length = %d, want %d: %v", len(got), len(want), got) + } else { + for i := range got { + if got[i].get().Cmp(want[i]) != 0 { + t.Fatalf("Ids[%d] = %v, want %v", i, got[i].get(), want[i]) + } + } + } +} + +func Test_ERC1155_SelfTransfer_ReorgValueLoss(t *testing.T) { + addrDesc := makeTestAddrDesc(0x1155) + contract := makeTestAddrDesc(0x1156) + acs := &unpackedAddrContracts{ + TotalTxs: 6, + Contracts: []unpackedAddrContract{ + { + Standard: bchain.MultiToken, + Contract: contract, + Txs: 6, + MultiTokenValues: unpackedMultiTokenValues{ + { + Id: unpackedBigInt{Value: big.NewInt(2)}, + Value: unpackedBigInt{Value: big.NewInt(10)}, + }, + }, + }, + }, + } + btxContract := ðBlockTxContract{ + from: addrDesc, + to: addrDesc, + contract: contract, + transferStandard: bchain.MultiToken, + idValues: []bchain.MultiTokenValue{ + { + Id: *big.NewInt(2), + Value: *big.NewInt(3), + }, + }, + } + + err := (&RocksDB{}).disconnectAddress([]byte{0x02}, false, addrDesc, btxContract, map[string]map[string]struct{}{}, map[string]*unpackedAddrContracts{ + string(addrDesc): acs, + }) + if err != nil { + t.Fatal(err) + } + got := acs.Contracts[0].MultiTokenValues + if len(got) != 1 { + t.Fatalf("MultiTokenValues length = %d, want 1", len(got)) + } + if got[0].Id.get().Cmp(big.NewInt(2)) != 0 { + t.Fatalf("MultiTokenValues[0].Id = %v, want 2", got[0].Id.get()) + } + if got[0].Value.get().Cmp(big.NewInt(10)) != 0 { + t.Fatalf("MultiTokenValues[0].Value = %v, want 10", got[0].Value.get()) + } +} + +func Test_packUnpackBlockTx(t *testing.T) { + parser := ethereumTestnetParser() + tests := []struct { + name string + blockTx ethBlockTx + pos int + }{ + { + name: "no contract", + blockTx: ethBlockTx{ + btxID: hexToBytes(dbtestdata.EthTxidB1T1), + from: addressToAddrDesc(dbtestdata.EthAddr3e, parser), + to: addressToAddrDesc(dbtestdata.EthAddr55, parser), + contracts: []ethBlockTxContract{}, + }, + pos: 73, + }, + { + name: "ERC20", + blockTx: ethBlockTx{ + btxID: hexToBytes(dbtestdata.EthTxidB1T1), + from: addressToAddrDesc(dbtestdata.EthAddr3e, parser), + to: addressToAddrDesc(dbtestdata.EthAddr55, parser), + contracts: []ethBlockTxContract{ + { + from: addressToAddrDesc(dbtestdata.EthAddr20, parser), + to: addressToAddrDesc(dbtestdata.EthAddr5d, parser), + contract: addressToAddrDesc(dbtestdata.EthAddrContract4a, parser), + transferStandard: bchain.FungibleToken, + value: *big.NewInt(10000), + }, + }, + }, + pos: 137, + }, + { + name: "multiple contracts", + blockTx: ethBlockTx{ + btxID: hexToBytes(dbtestdata.EthTxidB1T1), + from: addressToAddrDesc(dbtestdata.EthAddr3e, parser), + to: addressToAddrDesc(dbtestdata.EthAddr55, parser), + contracts: []ethBlockTxContract{ + { + 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), + transferStandard: bchain.NonFungibleToken, + value: *big.NewInt(13), + }, + { + 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), + Value: *big.NewInt(98765), + }, + { + Id: *big.NewInt(5566), + Value: *big.NewInt(12341234421), + }, + }, + }, + }, + }, + pos: 280, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := make([]byte, 0) + packed := packBlockTx(buf, &tt.blockTx) + got, pos, err := unpackBlockTx(packed, 0) + if err != nil { + t.Errorf("unpackBlockTx() error = %v", err) + return + } + if !reflect.DeepEqual(*got, tt.blockTx) { + t.Errorf("unpackBlockTx() got = %v, want %v", *got, tt.blockTx) + } + if pos != tt.pos { + t.Errorf("unpackBlockTx() pos = %v, want %v", pos, tt.pos) + } + }) + } +} + +func Test_packUnpackFourByteSignature(t *testing.T) { + tests := []struct { + name string + signature bchain.FourByteSignature + }{ + { + name: "no params", + signature: bchain.FourByteSignature{ + Name: "abcdef", + }, + }, + { + name: "one param", + signature: bchain.FourByteSignature{ + Name: "opqr", + Parameters: []string{"uint16"}, + }, + }, + { + name: "multiple params", + signature: bchain.FourByteSignature{ + Name: "xyz", + Parameters: []string{"address", "(bytes,uint256[],uint256)", "uint16"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := packFourByteSignature(&tt.signature) + if got, err := unpackFourByteSignature(buf); !reflect.DeepEqual(*got, tt.signature) || err != nil { + t.Errorf("packUnpackFourByteSignature() = %v, want %v, error %v", *got, tt.signature, 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_syscointype.go b/db/rocksdb_syscointype.go index e63ee65714..ad049cce72 100644 --- a/db/rocksdb_syscointype.go +++ b/db/rocksdb_syscointype.go @@ -1,27 +1,108 @@ package db import ( - "bytes" "encoding/hex" - "time" "fmt" - + "sync" + "time" + + vlq "github.com/bsm/go-vlq" "github.com/golang/glog" "github.com/juju/errors" - "github.com/flier/gorocksdb" - "github.com/syscoin/blockbook/bchain" - vlq "github.com/bsm/go-vlq" + gorocksdb "github.com/linxGnu/grocksdb" + syscoinwire "github.com/syscoin/syscoinwire/syscoin/wire" + "github.com/trezor/blockbook/bchain" ) + var AssetCache map[uint64]bchain.Asset var SetupAssetCacheFirstTime bool = true +var assetCacheMu sync.RWMutex // SYSCOIN + +const syscoinSYSXAssetGuid uint64 = 123456 // SYSCOIN + +// SYSCOIN: SYSX is the consensus bridge asset. Its display metadata is +// canonical even if an older DB/cache row contains incomplete native metadata. +func builtinSYSXAsset(transactions uint32) *bchain.Asset { + return &bchain.Asset{ + Transactions: transactions, + AssetObj: syscoinwire.AssetType{ + Symbol: []byte("SYSX"), + Precision: 8, + }, + MetaData: []byte("Syscoin Native Asset"), + } +} + +// SYSCOIN: indexing can continue with minimal metadata if NEVM metadata is +// temporarily unavailable; later asset cache updates can replace the display data. +func fallbackSyscoinAsset(guid uint64) *bchain.Asset { + return &bchain.Asset{ + AssetObj: syscoinwire.AssetType{ + Symbol: []byte(fmt.Sprintf("%d", guid)), + Precision: 8, + }, + } +} + +func isFallbackSyscoinAsset(guid uint64, asset *bchain.Asset) bool { + if asset == nil { + return false + } + return string(asset.AssetObj.Symbol) == fmt.Sprintf("%d", guid) && + asset.AssetObj.Precision == 8 && + len(asset.AssetObj.Contract) == 0 && + len(asset.MetaData) == 0 +} + +func (d *RocksDB) fetchNEVMAssetDetails(guid uint64) (*bchain.Asset, error) { + if d.chain == nil { + return nil, errors.New("GetAsset: asset not found in DB") + } + fetcher, ok := d.chain.(nevmAssetFetcher) + if !ok { + return nil, errors.New("GetAsset: NEVM asset fetcher is not configured") + } + return fetcher.FetchNEVMAssetDetails(guid) +} + // GetTxAssetsCallback is called by GetTransactions/GetTxAssets for each found tx type GetTxAssetsCallback func(txids []string) error -func (d *RocksDB) ConnectAllocationInput(addrDesc* bchain.AddressDescriptor, height uint32, version int32, balanceAsset *bchain.AssetBalance, btxID []byte, assetInfo* bchain.AssetInfo, blockTxAssetAddresses bchain.TxAssetAddressMap, assets map[uint64]*bchain.Asset, txAssets bchain.TxAssetMap) error { +type syscoinAssetParser interface { + PackAssetKey(assetGuid uint64, height uint32) []byte + UnpackAssetKey(key []byte) (uint64, uint32) + PackAssetTxIndex(txAsset *bchain.TxAsset) []byte + UnpackAssetTxIndex(buf []byte) []*bchain.TxAssetIndex + PackAsset(asset *bchain.Asset) ([]byte, error) + UnpackAsset(buf []byte) (*bchain.Asset, error) + GetAssetsMaskFromVersion(nVersion int32) bchain.AssetsMask +} + +type nevmAssetFetcher interface { + FetchNEVMAssetDetails(assetGuid uint64) (*bchain.Asset, error) +} + +func (d *RocksDB) syscoinAssetParser() syscoinAssetParser { + p, _ := d.chainParser.(syscoinAssetParser) + return p +} + +func packAssetGuid(guid uint64) []byte { + key := make([]byte, vlq.MaxLen64) + l := vlq.PutUint(key, guid) + return key[:l] +} + +func unpackAssetGuid(buf []byte) (uint64, int) { + return vlq.Uint(buf) +} + +func (d *RocksDB) ConnectAllocationInput(addrDesc *bchain.AddressDescriptor, height uint32, version int32, balanceAsset *bchain.AssetBalance, btxID []byte, assetInfo *bchain.AssetInfo, blockTxAssetAddresses bchain.TxAssetAddressMap, assets map[uint64]*bchain.Asset, txAssets bchain.TxAssetMap) error { dBAsset, err := d.GetAsset(assetInfo.AssetGuid, assets) if err != nil { - return errors.New(fmt.Sprintf("ConnectAllocationInput could not read asset %d, err: %v" , assetInfo.AssetGuid, err)) + glog.Warningf("ConnectAllocationInput using fallback asset %d: %v", assetInfo.AssetGuid, err) + dBAsset = fallbackSyscoinAsset(assetInfo.AssetGuid) } counted := d.addToAssetsMap(txAssets, assetInfo.AssetGuid, btxID, version, height) if !counted { @@ -40,10 +121,11 @@ func (d *RocksDB) ConnectAllocationInput(addrDesc* bchain.AddressDescriptor, hei return nil } -func (d *RocksDB) ConnectAllocationOutput(addrDesc* bchain.AddressDescriptor, height uint32, balanceAsset *bchain.AssetBalance, version int32, btxID []byte, assetInfo* bchain.AssetInfo, blockTxAssetAddresses bchain.TxAssetAddressMap, assets map[uint64]*bchain.Asset, txAssets bchain.TxAssetMap, memo []byte) error { +func (d *RocksDB) ConnectAllocationOutput(addrDesc *bchain.AddressDescriptor, height uint32, balanceAsset *bchain.AssetBalance, version int32, btxID []byte, assetInfo *bchain.AssetInfo, blockTxAssetAddresses bchain.TxAssetAddressMap, assets map[uint64]*bchain.Asset, txAssets bchain.TxAssetMap, memo []byte) error { dBAsset, err := d.GetAsset(assetInfo.AssetGuid, assets) if err != nil { - return errors.New(fmt.Sprintf("ConnectAllocationOutput could not read asset %d, err: %v" , assetInfo.AssetGuid, err)) + glog.Warningf("ConnectAllocationOutput using fallback asset %d: %v", assetInfo.AssetGuid, err) + dBAsset = fallbackSyscoinAsset(assetInfo.AssetGuid) } counted := d.addToAssetsMap(txAssets, assetInfo.AssetGuid, btxID, version, height) if !counted { @@ -59,10 +141,11 @@ func (d *RocksDB) ConnectAllocationOutput(addrDesc* bchain.AddressDescriptor, he return nil } -func (d *RocksDB) DisconnectAllocationOutput(addrDesc *bchain.AddressDescriptor, balanceAsset *bchain.AssetBalance, btxID []byte, assetInfo *bchain.AssetInfo, blockTxAssetAddresses bchain.TxAssetAddressMap, assets map[uint64]*bchain.Asset, assetFoundInTx func(asset uint64, btxID []byte) bool) error { +func (d *RocksDB) DisconnectAllocationOutput(addrDesc *bchain.AddressDescriptor, balanceAsset *bchain.AssetBalance, btxID []byte, assetInfo *bchain.AssetInfo, blockTxAssetAddresses bchain.TxAssetAddressMap, assets map[uint64]*bchain.Asset, assetFoundInTx func(asset uint64, btxID []byte) bool) error { dBAsset, err := d.GetAsset(assetInfo.AssetGuid, assets) if err != nil { - return errors.New(fmt.Sprintf("DisconnectAllocationOutput could not read asset %d, err: %v" , assetInfo.AssetGuid, err)) + glog.Warningf("DisconnectAllocationOutput using fallback asset %d: %v", assetInfo.AssetGuid, err) + dBAsset = fallbackSyscoinAsset(assetInfo.AssetGuid) } balanceAsset.BalanceSat.Sub(balanceAsset.BalanceSat, assetInfo.ValueSat) if balanceAsset.BalanceSat.Sign() < 0 { @@ -83,10 +166,11 @@ func (d *RocksDB) DisconnectAllocationOutput(addrDesc *bchain.AddressDescriptor, assets[assetInfo.AssetGuid] = dBAsset return nil } -func (d *RocksDB) DisconnectAllocationInput(addrDesc *bchain.AddressDescriptor, balanceAsset *bchain.AssetBalance, btxID []byte, assetInfo *bchain.AssetInfo, blockTxAssetAddresses bchain.TxAssetAddressMap, assets map[uint64]*bchain.Asset, assetFoundInTx func(asset uint64, btxID []byte) bool) error { +func (d *RocksDB) DisconnectAllocationInput(addrDesc *bchain.AddressDescriptor, balanceAsset *bchain.AssetBalance, btxID []byte, assetInfo *bchain.AssetInfo, blockTxAssetAddresses bchain.TxAssetAddressMap, assets map[uint64]*bchain.Asset, assetFoundInTx func(asset uint64, btxID []byte) bool) error { dBAsset, err := d.GetAsset(assetInfo.AssetGuid, assets) if err != nil { - return errors.New(fmt.Sprintf("DisconnectAllocationInput could not read asset %d, err: %v" , assetInfo.AssetGuid, err)) + glog.Warningf("DisconnectAllocationInput using fallback asset %d: %v", assetInfo.AssetGuid, err) + dBAsset = fallbackSyscoinAsset(assetInfo.AssetGuid) } balanceAsset.SentSat.Sub(balanceAsset.SentSat, assetInfo.ValueSat) balanceAsset.BalanceSat.Add(balanceAsset.BalanceSat, assetInfo.ValueSat) @@ -107,23 +191,33 @@ func (d *RocksDB) DisconnectAllocationInput(addrDesc *bchain.AddressDescriptor, func (d *RocksDB) SetupAssetCache() error { start := time.Now() + assetCacheMu.Lock() + defer assetCacheMu.Unlock() if AssetCache == nil { AssetCache = map[uint64]bchain.Asset{} } ro := gorocksdb.NewDefaultReadOptions() + defer ro.Destroy() ro.SetFillCache(false) - it := d.db.NewIteratorCF(d.ro, d.cfh[cfAssets]) + it := d.db.NewIteratorCF(ro, d.cfh[cfAssets]) defer it.Close() for it.SeekToFirst(); it.Valid(); it.Next() { - assetKey, _ := d.chainParser.UnpackVaruint64(it.Key().Data()) + assetKey, _ := unpackAssetGuid(it.Key().Data()) data := it.Value().Data() - assetDb, err := d.chainParser.UnpackAsset(data) + parser := d.syscoinAssetParser() + if parser == nil { + return errors.New("SetupAssetCache: Syscoin asset parser is not configured") + } + assetDb, err := parser.UnpackAsset(data) if err != nil { glog.Errorf("Failed unpacking asset GUID %d, data (hex): %s, err: %v", assetKey, hex.EncodeToString(data), err) break } + if assetKey == syscoinSYSXAssetGuid { + assetDb = builtinSYSXAsset(assetDb.Transactions) + } AssetCache[assetKey] = *assetDb } @@ -131,26 +225,31 @@ func (d *RocksDB) SetupAssetCache() error { return nil } - - func (d *RocksDB) storeAssets(wb *gorocksdb.WriteBatch, assets map[uint64]*bchain.Asset) error { if assets == nil { return nil } + assetCacheMu.Lock() + defer assetCacheMu.Unlock() if AssetCache == nil { AssetCache = map[uint64]bchain.Asset{} } for guid, asset := range assets { + if guid == syscoinSYSXAssetGuid { + asset = builtinSYSXAsset(asset.Transactions) + } AssetCache[guid] = *asset - key := make([]byte, vlq.MaxLen64) - l := d.chainParser.PackVaruint64(guid, key) - key = key[:l] + key := packAssetGuid(guid) // total supply of -1 signals asset to be removed from db - happens on disconnect of new asset if asset.AssetObj.TotalSupply == -1 { delete(AssetCache, guid) wb.DeleteCF(d.cfh[cfAssets], key) } else { - buf, err := d.chainParser.PackAsset(asset) + parser := d.syscoinAssetParser() + if parser == nil { + return errors.New("storeAssets: Syscoin asset parser is not configured") + } + buf, err := parser.PackAsset(asset) if err != nil { return err } @@ -161,14 +260,24 @@ func (d *RocksDB) storeAssets(wb *gorocksdb.WriteBatch, assets map[uint64]*bchai } func (d *RocksDB) GetAssetCache() *map[uint64]bchain.Asset { - return &AssetCache + assetCacheMu.RLock() + defer assetCacheMu.RUnlock() + cache := make(map[uint64]bchain.Asset, len(AssetCache)) + for guid, asset := range AssetCache { + cache[guid] = asset + } + return &cache } func (d *RocksDB) GetSetupAssetCacheFirstTime() bool { + assetCacheMu.RLock() + defer assetCacheMu.RUnlock() return SetupAssetCacheFirstTime } func (d *RocksDB) SetSetupAssetCacheFirstTime(cacheVal bool) { + assetCacheMu.Lock() + defer assetCacheMu.Unlock() SetupAssetCacheFirstTime = cacheVal } @@ -183,11 +292,23 @@ func (d *RocksDB) GetAsset(guid uint64, assets map[uint64]*bchain.Asset) (*bchai var assetDb *bchain.Asset var assetL1 *bchain.Asset var ok bool + var err error if assets != nil { if assetL1, ok = assets[guid]; ok { + if guid == syscoinSYSXAssetGuid { + return builtinSYSXAsset(assetL1.Transactions), nil + } + if isFallbackSyscoinAsset(guid, assetL1) { + if assetDb, err = d.fetchNEVMAssetDetails(guid); err == nil { + assetDb.Transactions = assetL1.Transactions + assets[guid] = assetDb + return assetDb, nil + } + } return assetL1, nil } } + assetCacheMu.Lock() if AssetCache == nil { AssetCache = map[uint64]bchain.Asset{} // so it will store later in cache @@ -195,49 +316,91 @@ func (d *RocksDB) GetAsset(guid uint64, assets map[uint64]*bchain.Asset) (*bchai } else { var assetDbCache, ok = AssetCache[guid] if ok { + if guid == syscoinSYSXAssetGuid { + assetDb = builtinSYSXAsset(assetDbCache.Transactions) + AssetCache[guid] = *assetDb + assetCacheMu.Unlock() + return assetDb, nil + } + if isFallbackSyscoinAsset(guid, &assetDbCache) { + assetCacheMu.Unlock() + if assetDb, err = d.fetchNEVMAssetDetails(guid); err == nil { + assetDb.Transactions = assetDbCache.Transactions + assetCacheMu.Lock() + AssetCache[guid] = *assetDb + assetCacheMu.Unlock() + return assetDb, nil + } + return &assetDbCache, nil + } + assetCacheMu.Unlock() return &assetDbCache, nil } } - key := make([]byte, vlq.MaxLen64) - l := d.chainParser.PackVaruint64(guid, key) - key = key[:l] + assetCacheMu.Unlock() + key := packAssetGuid(guid) val, err := d.db.GetCF(d.ro, d.cfh[cfAssets], key) if err != nil { return nil, err } + defer val.Free() // nil data means the key was not found in DB if val.Data() == nil { - if d.chain == nil { - return nil, errors.New("GetAsset: asset not found in DB") + if guid == syscoinSYSXAssetGuid { + assetDb = builtinSYSXAsset(0) + assetCacheMu.Lock() + AssetCache[guid] = *assetDb + assetCacheMu.Unlock() + return assetDb, nil } - assetDb, err := d.chain.FetchNEVMAssetDetails(guid) + assetDb, err := d.fetchNEVMAssetDetails(guid) if err != nil { return nil, err } - + + assetCacheMu.Lock() AssetCache[guid] = *assetDb + assetCacheMu.Unlock() return assetDb, nil } - defer val.Free() buf := val.Data() if len(buf) == 0 { return nil, errors.New("GetAsset: empty value in asset db") } - assetDb, err = d.chainParser.UnpackAsset(buf) + parser := d.syscoinAssetParser() + if parser == nil { + return nil, errors.New("GetAsset: Syscoin asset parser is not configured") + } + assetDb, err = parser.UnpackAsset(buf) if err != nil { return nil, err } + if guid == syscoinSYSXAssetGuid { + assetDb = builtinSYSXAsset(assetDb.Transactions) + } + if isFallbackSyscoinAsset(guid, assetDb) { + if fetched, fetchErr := d.fetchNEVMAssetDetails(guid); fetchErr == nil { + fetched.Transactions = assetDb.Transactions + assetDb = fetched + } + } // cache miss, add it, we also add it on storeAsset but on API queries we should not have to wait until a block // with this asset to store it in cache if !ok { + assetCacheMu.Lock() AssetCache[guid] = *assetDb + assetCacheMu.Unlock() } return assetDb, nil } func (d *RocksDB) storeTxAssets(wb *gorocksdb.WriteBatch, txassets bchain.TxAssetMap) error { for key, txAsset := range txassets { - buf := d.chainParser.PackAssetTxIndex(txAsset) + parser := d.syscoinAssetParser() + if parser == nil { + return errors.New("storeTxAssets: Syscoin asset parser is not configured") + } + buf := parser.PackAssetTxIndex(txAsset) wb.PutCF(d.cfh[cfTxAssets], []byte(key), buf) } return nil @@ -246,8 +409,12 @@ func (d *RocksDB) storeTxAssets(wb *gorocksdb.WriteBatch, txassets bchain.TxAsse // GetTxAssets finds all asset transactions for each asset // Transaction are passed to callback function in the order from newest block to the oldest func (d *RocksDB) GetTxAssets(assetGuid uint64, lower uint32, higher uint32, assetsBitMask bchain.AssetsMask, fn GetTxAssetsCallback) (err error) { - startKey := d.chainParser.PackAssetKey(assetGuid, higher) - stopKey := d.chainParser.PackAssetKey(assetGuid, lower) + parser := d.syscoinAssetParser() + if parser == nil { + return errors.New("GetTxAssets: Syscoin asset parser is not configured") + } + startKey := parser.PackAssetKey(assetGuid, higher) + stopKey := parser.PackAssetKey(assetGuid, lower) it := d.db.NewIteratorCF(d.ro, d.cfh[cfTxAssets]) defer it.Close() for it.Seek(startKey); it.Valid(); it.Next() { @@ -256,7 +423,7 @@ func (d *RocksDB) GetTxAssets(assetGuid uint64, lower uint32, higher uint32, ass if bytes.Compare(key, stopKey) > 0 { break } - txIndexes := d.chainParser.UnpackAssetTxIndex(val) + txIndexes := parser.UnpackAssetTxIndex(val) if txIndexes != nil { txids := []string{} for _, txIndex := range txIndexes { @@ -283,7 +450,11 @@ func (d *RocksDB) GetTxAssets(assetGuid uint64, lower uint32, higher uint32, ass func (d *RocksDB) addToAssetsMap(txassets bchain.TxAssetMap, assetGuid uint64, btxID []byte, version int32, height uint32) bool { // check that the asset was already processed in this block // if not found, it has certainly not been counted - key := string(d.chainParser.PackAssetKey(assetGuid, height)) + parser := d.syscoinAssetParser() + if parser == nil { + return false + } + key := string(parser.PackAssetKey(assetGuid, height)) at, found := txassets[key] if found { // if the tx is already in the slice @@ -296,10 +467,11 @@ func (d *RocksDB) addToAssetsMap(txassets bchain.TxAssetMap, assetGuid uint64, b at = &bchain.TxAsset{Txs: []*bchain.TxAssetIndex{}} txassets[key] = at } - at.Txs = append(at.Txs, &bchain.TxAssetIndex{Type: d.chainParser.GetAssetsMaskFromVersion(version), BtxID: btxID}) + at.Txs = append(at.Txs, &bchain.TxAssetIndex{Type: parser.GetAssetsMaskFromVersion(version), BtxID: btxID}) at.Height = height return false } + // to control Transfer add/remove func (d *RocksDB) addToAssetAddressMap(txassetAddresses bchain.TxAssetAddressMap, assetGuid uint64, btxID []byte, addrDesc *bchain.AddressDescriptor) bool { at, found := txassetAddresses[assetGuid] @@ -316,4 +488,4 @@ func (d *RocksDB) addToAssetAddressMap(txassetAddresses bchain.TxAssetAddressMap } at.Txs = append(at.Txs, &bchain.TxAssetAddressIndex{AddrDesc: *addrDesc, BtxID: btxID}) return false -} \ No newline at end of file +} diff --git a/db/rocksdb_syscointype_test.go b/db/rocksdb_syscointype_test.go index 9005dd085c..ad8b89a54e 100644 --- a/db/rocksdb_syscointype_test.go +++ b/db/rocksdb_syscointype_test.go @@ -3,16 +3,18 @@ package db import ( - "testing" "encoding/hex" - - "github.com/martinboehm/btcutil/chaincfg" + "math/big" + "testing" + vlq "github.com/bsm/go-vlq" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/common" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/bchain/coins/sys" - "github.com/syscoin/blockbook/tests/dbtestdata" + "github.com/martinboehm/btcutil/chaincfg" + syscoinwire "github.com/syscoin/syscoinwire/syscoin/wire" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/bchain/coins/sys" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/tests/dbtestdata" ) type testSyscoinParser struct { @@ -21,30 +23,80 @@ type testSyscoinParser struct { func syscoinTestParser() *syscoin.SyscoinParser { return syscoin.NewSyscoinParser(syscoin.GetChainParams("main"), - &btc.Configuration{BlockAddressesToKeep: 2}) + &btc.Configuration{BlockAddressesToKeep: 2}) } func txIndexesHexSyscoin(tx string, assetsMask bchain.AssetsMask, assetGuids []uint64, indexes []int32, d *RocksDB) string { buf := make([]byte, vlq.MaxLen32) varBuf := make([]byte, vlq.MaxLen64) - l := d.chainParser.PackVaruint(uint(assetsMask), buf) + l := packVaruint(uint(assetsMask), buf) tx = hex.EncodeToString(buf[:l]) + tx for i, index := range indexes { index <<= 1 if i == len(indexes)-1 { index |= 1 } - l = d.chainParser.PackVarint32(index, buf) + l = packVarint32(index, buf) tx += hex.EncodeToString(buf[:l]) } - l = d.chainParser.PackVaruint(uint(len(assetGuids)), buf) + l = packVaruint(uint(len(assetGuids)), buf) tx += hex.EncodeToString(buf[:l]) for _, asset := range assetGuids { - l = d.chainParser.PackVaruint64(asset, varBuf) + l = packVaruint64(asset, varBuf) tx += hex.EncodeToString(varBuf[:l]) } return tx -} +} + +func varintToHex(i int) string { + buf := make([]byte, vlq.MaxLen64) + l := packVarint(i, buf) + return hex.EncodeToString(buf[:l]) +} + +func loadSyscoinAssets(d *RocksDB, tx *bchain.Tx) error { + parser, ok := d.chainParser.(interface { + LoadAssets(*bchain.Tx) error + }) + if !ok { + return nil + } + return parser.LoadAssets(tx) +} + +func syscoinAssetTestBlock(parser bchain.BlockChainParser, height uint32, hash, txid string, vin []bchain.Vin, voutAddr string, assetGuid uint64, assetValue int64) *bchain.Block { + return &bchain.Block{ + BlockHeader: bchain.BlockHeader{ + Height: height, + Hash: hash, + Size: 1, + Time: int64(1598557000 + height), + }, + Txs: []bchain.Tx{ + { + Txid: txid, + Version: syscoin.SYSCOIN_TX_VERSION_ALLOCATION_SEND, + Vin: vin, + Vout: []bchain.Vout{ + { + N: 0, + ScriptPubKey: bchain.ScriptPubKey{ + Hex: dbtestdata.AddressToPubKeyHex(voutAddr, parser), + }, + ValueSat: *big.NewInt(0), + AssetInfo: &bchain.AssetInfo{ + AssetGuid: assetGuid, + ValueSat: big.NewInt(assetValue), + }, + }, + }, + Blocktime: int64(1598557000 + height), + Time: int64(1598557000 + height), + }, + }, + } +} + func verifyAfterSyscoinTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect bool) { // Check cfHeight if err := checkColumn(d, cfHeight, []keyPair{ @@ -60,7 +112,7 @@ func verifyAfterSyscoinTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect bool if err := checkColumn(d, cfAddresses, []keyPair{ { addressKeyHex(dbtestdata.AddrS1, 112, d), - txIndexesHexSyscoin(dbtestdata.TxidS1T0, bchain.BaseCoinMask, []uint64{}, []int32{0}, d), + txIndexesHex(dbtestdata.TxidS1T0, []int32{0}), nil, }, }); err != nil { @@ -70,9 +122,9 @@ func verifyAfterSyscoinTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect bool if err := checkColumn(d, cfAddressBalance, []keyPair{ { dbtestdata.AddressToPubKeyHex(dbtestdata.AddrS1, d.chainParser), - varuintToHex(1) + bigintToHex(dbtestdata.SatZero, d) + bigintToHex(dbtestdata.SatS1T0A1, d) + + varuintToHex(1) + bigintToHex(dbtestdata.SatZero) + bigintToHex(dbtestdata.SatS1T0A1) + varuintToHex(0) + // zero assets - dbtestdata.TxidS1T0 + varuintToHex(0) + varuintToHex(112) + bigintToHex(dbtestdata.SatS1T0A1, d) + + dbtestdata.TxidS1T0 + varuintToHex(0) + varuintToHex(112) + bigintToHex(dbtestdata.SatS1T0A1) + varuintToHex(0), // no asset info nil, }, @@ -98,239 +150,367 @@ func verifyAfterSyscoinTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect bool t.Fatal(err) } } + // verifyAfterSyscoinTypeBlock2 checks DB after block2 is connected func verifyAfterSyscoinTypeBlock2(t *testing.T, d *RocksDB) { - // CFHeight - if err := checkColumn(d, cfHeight, []keyPair{ - { - "00000071", - "00000cade5f8d530b3f0a3b6c9dceaca50627838f2c6fffb807390cba71974e7" + - uintToHex(1598557012) + varuintToHex(1) + varuintToHex(554), - nil, - }, - { - "00000070", - "00000797cfd9074de37a557bf0d47bd86c45846f31e163ba688e14dfc498527a" + - uintToHex(1598556954) + varuintToHex(1) + varuintToHex(503), - nil, - }, - }); err != nil { - t.Fatal(err) - } - - // Only coinbase TX in block2 => output to AddrS2 - if err := checkColumn(d, cfAddresses, []keyPair{ - { + // CFHeight + if err := checkColumn(d, cfHeight, []keyPair{ + { + "00000071", + "00000cade5f8d530b3f0a3b6c9dceaca50627838f2c6fffb807390cba71974e7" + + uintToHex(1598557012) + varuintToHex(1) + varuintToHex(554), + nil, + }, + { + "00000070", + "00000797cfd9074de37a557bf0d47bd86c45846f31e163ba688e14dfc498527a" + + uintToHex(1598556954) + varuintToHex(1) + varuintToHex(503), + nil, + }, + }); err != nil { + t.Fatal(err) + } + + // Only coinbase TX in block2 => output to AddrS2 + if err := checkColumn(d, cfAddresses, []keyPair{ + { addressKeyHex(dbtestdata.AddrS1, 112, d), - txIndexesHexSyscoin(dbtestdata.TxidS1T0, bchain.BaseCoinMask, []uint64{}, []int32{0}, d), + txIndexesHex(dbtestdata.TxidS1T0, []int32{0}), + nil, + }, + { + addressKeyHex(dbtestdata.AddrS2, 113, d), + txIndexesHex(dbtestdata.TxidS2T0, []int32{0}), nil, }, - { - addressKeyHex(dbtestdata.AddrS2, 113, d), - txIndexesHexSyscoin(dbtestdata.TxidS2T0, bchain.BaseCoinMask, []uint64{}, []int32{0}, d), - nil, - }, - - }); err != nil { - t.Fatal(err) - } - - // Check address balance for AddrS2 - if err := checkColumn(d, cfAddressBalance, []keyPair{ - { + }); err != nil { + t.Fatal(err) + } + + // Check address balance for AddrS2 + if err := checkColumn(d, cfAddressBalance, []keyPair{ + { dbtestdata.AddressToPubKeyHex(dbtestdata.AddrS1, d.chainParser), - varuintToHex(1) + bigintToHex(dbtestdata.SatZero, d) + bigintToHex(dbtestdata.SatS1T0A1, d) + + varuintToHex(1) + bigintToHex(dbtestdata.SatZero) + bigintToHex(dbtestdata.SatS1T0A1) + + varuintToHex(0) + // zero assets + dbtestdata.TxidS1T0 + varuintToHex(0) + varuintToHex(112) + bigintToHex(dbtestdata.SatS1T0A1) + + varuintToHex(0), // no asset info + nil, + }, + { + dbtestdata.AddressToPubKeyHex(dbtestdata.AddrS2, d.chainParser), + varuintToHex(1) + bigintToHex(dbtestdata.SatZero) + bigintToHex(dbtestdata.SatS2T0A1) + varuintToHex(0) + // zero assets - dbtestdata.TxidS1T0 + varuintToHex(0) + varuintToHex(112) + bigintToHex(dbtestdata.SatS1T0A1, d) + + dbtestdata.TxidS2T0 + varuintToHex(0) + varuintToHex(113) + bigintToHex(dbtestdata.SatS2T0A1) + varuintToHex(0), // no asset info nil, }, - { - dbtestdata.AddressToPubKeyHex(dbtestdata.AddrS2, d.chainParser), - varuintToHex(1) + bigintToHex(dbtestdata.SatZero, d) + bigintToHex(dbtestdata.SatS2T0A1, d) + - varuintToHex(0) + // zero assets - dbtestdata.TxidS2T0 + varuintToHex(0) + varuintToHex(113) + bigintToHex(dbtestdata.SatS2T0A1, d) + - varuintToHex(0), // no asset info - nil, - }, - }); err != nil { - t.Fatal(err) - } + }); err != nil { + t.Fatal(err) + } } - // TestRocksDB_Index_SyscoinType ensures we can connect/disconnect Syscoin blocks (v5) func TestRocksDB_Index_SyscoinType(t *testing.T) { - d := setupRocksDB(t, &testSyscoinParser{ - SyscoinParser: syscoinTestParser(), - }) - defer closeAndDestroyRocksDB(t, d) - - // No blocks connected yet => 0 length blockTimes - if len(d.is.BlockTimes) != 0 { - t.Fatalf("Expecting is.BlockTimes 0, got %d", len(d.is.BlockTimes)) - } - - // Connect block1 - block1 := dbtestdata.GetTestSyscoinTypeBlock1(d.chainParser) - for i := range block1.Txs { - tx := &block1.Txs[i] - err := d.chainParser.LoadAssets(tx) // no-op for coinbase - if err != nil { - t.Fatal(err) - } - } - if err := d.ConnectBlock(block1); err != nil { - t.Fatal(err) - } - verifyAfterSyscoinTypeBlock1(t, d, false) - - // Should have 1 blockTime - if len(d.is.BlockTimes) != 1 { - t.Fatalf("Expecting is.BlockTimes 1, got %d", len(d.is.BlockTimes)) - } - - // Connect block2 - block2 := dbtestdata.GetTestSyscoinTypeBlock2(d.chainParser) - for i := range block2.Txs { - tx := &block2.Txs[i] - err := d.chainParser.LoadAssets(tx) // no-op - if err != nil { - t.Fatal(err) - } - } - if err := d.ConnectBlock(block2); err != nil { - t.Fatal(err) - } - verifyAfterSyscoinTypeBlock2(t, d) - - // Should have 2 blockTimes - if len(d.is.BlockTimes) != 2 { - t.Fatalf("Expecting is.BlockTimes 2, got %d", len(d.is.BlockTimes)) - } - - // Test some DB queries - // Since block1 pays to AddrS1 and block2 pays to AddrS2, let's do a getTx check - verifyGetTransactions(t, d, dbtestdata.AddrS1, 0, 200000, []txidIndex{ - {dbtestdata.TxidS1T0, 0}, // coinbase output - }, nil) - verifyGetTransactions(t, d, dbtestdata.AddrS2, 0, 200000, []txidIndex{ - {dbtestdata.TxidS2T0, 0}, // coinbase output - }, nil) - - // Check best block - height, hash, err := d.GetBestBlock() - if err != nil { - t.Fatal(err) - } - if height != 113 { - t.Fatalf("GetBestBlock: got height %d, expected 113", height) - } - if hash != "00000cade5f8d530b3f0a3b6c9dceaca50627838f2c6fffb807390cba71974e7" { - t.Fatalf("GetBestBlock: got hash %v, expected 00000cade5f8d530b3f0a3b6c9dceaca50627838f2c6fffb807390cba71974e7", hash) - } - - // Block1 hash - h, err := d.GetBlockHash(112) - if err != nil { - t.Fatal(err) - } - if h != "00000797cfd9074de37a557bf0d47bd86c45846f31e163ba688e14dfc498527a" { - t.Fatalf("Block#112 hash mismatch, got %s", h) - } - - // Disconnect block2 - if err := d.DisconnectBlockRangeBitcoinType(113, 113); err != nil { - t.Fatal(err) - } - verifyAfterSyscoinTypeBlock1(t, d, false) - - // Reconnect block2 - if err := d.ConnectBlock(block2); err != nil { - t.Fatal(err) - } - verifyAfterSyscoinTypeBlock2(t, d) - // blockTxs - if err := checkColumn(d, cfBlockTxs, []keyPair{ - { - "00000071", - dbtestdata.TxidS2T0 + "01" + - "0000000000000000000000000000000000000000000000000000000000000000" + varintToHex(0), - nil, - }, - { - "00000070", - dbtestdata.TxidS1T0 + "01" + - "0000000000000000000000000000000000000000000000000000000000000000" + varintToHex(0), - nil, - }, - }); err != nil { - t.Fatal(err) - } + d := setupRocksDB(t, &testSyscoinParser{ + SyscoinParser: syscoinTestParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + // No blocks connected yet => 0 length blockTimes + if len(d.is.BlockTimes) != 0 { + t.Fatalf("Expecting is.BlockTimes 0, got %d", len(d.is.BlockTimes)) + } + + // Connect block1 + block1 := dbtestdata.GetTestSyscoinTypeBlock1(d.chainParser) + for i := range block1.Txs { + tx := &block1.Txs[i] + err := loadSyscoinAssets(d, tx) // no-op for coinbase + if err != nil { + t.Fatal(err) + } + } + if err := d.ConnectBlock(block1); err != nil { + t.Fatal(err) + } + verifyAfterSyscoinTypeBlock1(t, d, false) + + // Should have block times indexed up to height 112. + if len(d.is.BlockTimes) != 113 { + t.Fatalf("Expecting is.BlockTimes 113, got %d", len(d.is.BlockTimes)) + } + + // Connect block2 + block2 := dbtestdata.GetTestSyscoinTypeBlock2(d.chainParser) + for i := range block2.Txs { + tx := &block2.Txs[i] + err := loadSyscoinAssets(d, tx) // no-op + if err != nil { + t.Fatal(err) + } + } + if err := d.ConnectBlock(block2); err != nil { + t.Fatal(err) + } + verifyAfterSyscoinTypeBlock2(t, d) + + // Should have 2 blockTimes + if len(d.is.BlockTimes) != 114 { + t.Fatalf("Expecting is.BlockTimes 114, got %d", len(d.is.BlockTimes)) + } + + // Test some DB queries + // Since block1 pays to AddrS1 and block2 pays to AddrS2, let's do a getTx check + verifyGetTransactions(t, d, dbtestdata.AddrS1, 0, 200000, []txidIndex{ + {dbtestdata.TxidS1T0, 0}, // coinbase output + }, nil) + verifyGetTransactions(t, d, dbtestdata.AddrS2, 0, 200000, []txidIndex{ + {dbtestdata.TxidS2T0, 0}, // coinbase output + }, nil) + + // Check best block + height, hash, err := d.GetBestBlock() + if err != nil { + t.Fatal(err) + } + if height != 113 { + t.Fatalf("GetBestBlock: got height %d, expected 113", height) + } + if hash != "00000cade5f8d530b3f0a3b6c9dceaca50627838f2c6fffb807390cba71974e7" { + t.Fatalf("GetBestBlock: got hash %v, expected 00000cade5f8d530b3f0a3b6c9dceaca50627838f2c6fffb807390cba71974e7", hash) + } + + // Block1 hash + h, err := d.GetBlockHash(112) + if err != nil { + t.Fatal(err) + } + if h != "00000797cfd9074de37a557bf0d47bd86c45846f31e163ba688e14dfc498527a" { + t.Fatalf("Block#112 hash mismatch, got %s", h) + } + + // Disconnect block2 + if err := d.DisconnectBlockRangeBitcoinType(113, 113); err != nil { + t.Fatal(err) + } + verifyAfterSyscoinTypeBlock1(t, d, false) + + // Reconnect block2 + if err := d.ConnectBlock(block2); err != nil { + t.Fatal(err) + } + verifyAfterSyscoinTypeBlock2(t, d) + // blockTxs + if err := checkColumn(d, cfBlockTxs, []keyPair{ + { + "00000071", + dbtestdata.TxidS2T0 + "01" + + "0000000000000000000000000000000000000000000000000000000000000000" + varintToHex(0), + nil, + }, + { + "00000070", + dbtestdata.TxidS1T0 + "01" + + "0000000000000000000000000000000000000000000000000000000000000000" + varintToHex(0), + nil, + }, + }); err != nil { + t.Fatal(err) + } +} + +func TestRocksDB_DisconnectSyscoinAssetRestoresSpentUTXO(t *testing.T) { + d := setupRocksDB(t, &testSyscoinParser{ + SyscoinParser: syscoinTestParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + const assetGuid uint64 = 12345 + const assetValue int64 = 25 + AssetCache = map[uint64]bchain.Asset{ + assetGuid: {}, + } + + txid1 := "1111111111111111111111111111111111111111111111111111111111111111" + txid2 := "2222222222222222222222222222222222222222222222222222222222222222" + block1 := syscoinAssetTestBlock( + d.chainParser, + 1, + "0000000000000000000000000000000000000000000000000000000000000001", + txid1, + []bchain.Vin{{Coinbase: "01"}}, + dbtestdata.AddrS1, + assetGuid, + assetValue, + ) + block2 := syscoinAssetTestBlock( + d.chainParser, + 2, + "0000000000000000000000000000000000000000000000000000000000000002", + txid2, + []bchain.Vin{{Txid: txid1, Vout: 0}}, + dbtestdata.AddrS2, + assetGuid, + assetValue, + ) + + if err := d.ConnectBlock(block1); err != nil { + t.Fatal(err) + } + if err := d.ConnectBlock(block2); err != nil { + t.Fatal(err) + } + if err := d.DisconnectBlockRangeBitcoinType(2, 2); err != nil { + t.Fatal(err) + } + + addrDesc1, err := d.chainParser.GetAddrDescFromAddress(dbtestdata.AddrS1) + if err != nil { + t.Fatal(err) + } + balance1, err := d.GetAddrDescBalance(addrDesc1, addressBalanceDetailUTXOIndexed) + if err != nil { + t.Fatal(err) + } + if balance1 == nil { + t.Fatal("missing AddrS1 balance after disconnect") + } + assetBalance1 := balance1.AssetBalances[assetGuid] + if assetBalance1 == nil { + t.Fatal("missing restored AddrS1 asset balance after disconnect") + } + if assetBalance1.BalanceSat.Cmp(big.NewInt(assetValue)) != 0 { + t.Fatalf("AddrS1 asset balance = %s, want %d", assetBalance1.BalanceSat, assetValue) + } + if len(balance1.Utxos) != 1 || balance1.Utxos[0].AssetInfo == nil { + t.Fatalf("restored UTXO missing asset info: %+v", balance1.Utxos) + } + if balance1.Utxos[0].AssetInfo.AssetGuid != assetGuid || balance1.Utxos[0].AssetInfo.ValueSat.Cmp(big.NewInt(assetValue)) != 0 { + t.Fatalf("restored UTXO asset info = %+v, want asset %d value %d", balance1.Utxos[0].AssetInfo, assetGuid, assetValue) + } + + addrDesc2, err := d.chainParser.GetAddrDescFromAddress(dbtestdata.AddrS2) + if err != nil { + t.Fatal(err) + } + balance2, err := d.GetAddrDescBalance(addrDesc2, addressBalanceDetailUTXOIndexed) + if err != nil { + t.Fatal(err) + } + if balance2 != nil && balance2.AssetBalances[assetGuid] != nil && balance2.AssetBalances[assetGuid].BalanceSat.Sign() != 0 { + t.Fatalf("AddrS2 asset balance survived disconnected block: %+v", balance2.AssetBalances[assetGuid]) + } +} + +func TestRocksDB_GetAssetUsesBuiltinSYSXFallback(t *testing.T) { + d := setupRocksDB(t, &testSyscoinParser{ + SyscoinParser: syscoinTestParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + AssetCache = nil + asset, err := d.GetAsset(syscoinSYSXAssetGuid, nil) + if err != nil { + t.Fatal(err) + } + if string(asset.AssetObj.Symbol) != "SYSX" { + t.Fatalf("SYSX symbol = %q", asset.AssetObj.Symbol) + } + if asset.AssetObj.Precision != 8 { + t.Fatalf("SYSX precision = %d, want 8", asset.AssetObj.Precision) + } + if len(asset.AssetObj.Contract) != 0 { + t.Fatalf("SYSX contract length = %d, want empty native contract", len(asset.AssetObj.Contract)) + } + if string(asset.MetaData) != "Syscoin Native Asset" { + t.Fatalf("SYSX metadata = %q", asset.MetaData) + } + if cached, ok := AssetCache[syscoinSYSXAssetGuid]; !ok || string(cached.AssetObj.Symbol) != "SYSX" { + t.Fatalf("SYSX asset not cached correctly: ok=%v asset=%+v", ok, cached) + } + + AssetCache = map[uint64]bchain.Asset{ + syscoinSYSXAssetGuid: { + Transactions: 150, + AssetObj: syscoinwire.AssetType{Symbol: []byte("SYSX"), Precision: 8}, + MetaData: []byte("SYSX"), + }, + } + asset, err = d.GetAsset(syscoinSYSXAssetGuid, nil) + if err != nil { + t.Fatal(err) + } + if asset.Transactions != 150 { + t.Fatalf("SYSX transactions = %d, want preserved count 150", asset.Transactions) + } + if string(asset.MetaData) != "Syscoin Native Asset" || len(asset.AssetObj.Contract) != 0 { + t.Fatalf("SYSX cached metadata was not canonicalized: %+v", asset) + } } // Test_BulkConnect_SyscoinType verifies that we can bulk-connect two Syscoin blocks // (containing simple coinbase transactions) without any asset creation/updates. func Test_BulkConnect_SyscoinType(t *testing.T) { - d := setupRocksDB(t, &testSyscoinParser{ - SyscoinParser: syscoinTestParser(), - }) - defer closeAndDestroyRocksDB(t, d) - - // The DB should be in an inconsistent state until BulkConnect is finished - bc, err := d.InitBulkConnect() - if err != nil { - t.Fatal(err) - } - if d.is.DbState != common.DbStateInconsistent { - t.Fatalf("Expected DbStateInconsistent, got %v", d.is.DbState) - } - - // Nothing connected => blockTimes should be empty - if len(d.is.BlockTimes) != 0 { - t.Fatalf("Expecting is.BlockTimes=0 initially, got %d", len(d.is.BlockTimes)) - } - - // Prepare block1 - block1 := dbtestdata.GetTestSyscoinTypeBlock1(d.chainParser) - for i := range block1.Txs { - tx := &block1.Txs[i] - // LoadAssets might do nothing here for coinbase, but we call it to keep the flow consistent - if err := d.chainParser.LoadAssets(tx); err != nil { - t.Fatal(err) - } - } - // Connect block1 in bulk mode without flushing - if err := bc.ConnectBlock(block1, false); err != nil { - t.Fatal(err) - } - - // Prepare block2 - block2 := dbtestdata.GetTestSyscoinTypeBlock2(d.chainParser) - for i := range block2.Txs { - tx := &block2.Txs[i] - if err := d.chainParser.LoadAssets(tx); err != nil { - t.Fatal(err) - } - } - // Connect block2 with flush - if err := bc.ConnectBlock(block2, true); err != nil { - t.Fatal(err) - } - - // Close the bulk connection => data is fully committed - if err := bc.Close(); err != nil { - t.Fatal(err) - } - - // Now DB state is expected to be open - if d.is.DbState != common.DbStateOpen { - t.Fatalf("Expected DbStateOpen after bulk connect, got %v", d.is.DbState) - } - - // Validate final DB state. This reuses the same verification method from the single-block test. - // i.e. block2 is connected => we expect final state from block2 - verifyAfterSyscoinTypeBlock2(t, d) + d := setupRocksDB(t, &testSyscoinParser{ + SyscoinParser: syscoinTestParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + // The DB should be in an inconsistent state until BulkConnect is finished + bc, err := d.InitBulkConnect() + if err != nil { + t.Fatal(err) + } + if d.is.DbState != common.DbStateInconsistent { + t.Fatalf("Expected DbStateInconsistent, got %v", d.is.DbState) + } + + // Nothing connected => blockTimes should be empty + if len(d.is.BlockTimes) != 0 { + t.Fatalf("Expecting is.BlockTimes=0 initially, got %d", len(d.is.BlockTimes)) + } + + // Prepare block1 + block1 := dbtestdata.GetTestSyscoinTypeBlock1(d.chainParser) + for i := range block1.Txs { + tx := &block1.Txs[i] + // LoadAssets might do nothing here for coinbase, but we call it to keep the flow consistent + if err := loadSyscoinAssets(d, tx); err != nil { + t.Fatal(err) + } + } + // Connect block1 in bulk mode without flushing + if err := bc.ConnectBlock(block1, false); err != nil { + t.Fatal(err) + } + + // Prepare block2 + block2 := dbtestdata.GetTestSyscoinTypeBlock2(d.chainParser) + for i := range block2.Txs { + tx := &block2.Txs[i] + if err := loadSyscoinAssets(d, tx); err != nil { + t.Fatal(err) + } + } + // Connect block2 with flush + if err := bc.ConnectBlock(block2, true); err != nil { + t.Fatal(err) + } + + // Close the bulk connection => data is fully committed + if err := bc.Close(); err != nil { + t.Fatal(err) + } + + // Now DB state is expected to be open + if d.is.DbState != common.DbStateOpen { + t.Fatalf("Expected DbStateOpen after bulk connect, got %v", d.is.DbState) + } + + // Validate final DB state. This reuses the same verification method from the single-block test. + // i.e. block2 is connected => we expect final state from block2 + verifyAfterSyscoinTypeBlock2(t, d) if err := checkColumn(d, cfBlockTxs, []keyPair{ { "00000071", @@ -342,12 +522,12 @@ func Test_BulkConnect_SyscoinType(t *testing.T) { t.Fatal(err) } } - // Check that blockTimes was populated for all blocks from 0..113 inclusive => length 114 - // (The code increments blockTimes even for empty initial heights.) - if len(d.is.BlockTimes) != 114 { - t.Fatalf("Expecting is.BlockTimes=114, got %d", len(d.is.BlockTimes)) - } + // Check that blockTimes was populated for all blocks from 0..113 inclusive => length 114 + // (The code increments blockTimes even for empty initial heights.) + if len(d.is.BlockTimes) != 114 { + t.Fatalf("Expecting is.BlockTimes=114, got %d", len(d.is.BlockTimes)) + } - // Reset chaincfg if needed (depends on your test environment) - chaincfg.ResetParams() + // Reset chaincfg if needed (depends on your test environment) + chaincfg.ResetParams() } diff --git a/db/rocksdb_test.go b/db/rocksdb_test.go index f3794d591d..16f2dffd43 100644 --- a/db/rocksdb_test.go +++ b/db/rocksdb_test.go @@ -5,22 +5,21 @@ package db import ( "encoding/binary" "encoding/hex" - "io/ioutil" "math/big" "os" "reflect" "sort" "strings" "testing" - "time" vlq "github.com/bsm/go-vlq" "github.com/juju/errors" + "github.com/linxGnu/grocksdb" "github.com/martinboehm/btcutil/chaincfg" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/common" - "github.com/syscoin/blockbook/tests/dbtestdata" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/tests/dbtestdata" ) // simplified explanation of signed varint packing, used in many index data structures @@ -44,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, 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) } @@ -67,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 @@ -82,15 +115,9 @@ func spentAddressToPubKeyHexWithLength(addr string, t *testing.T, d *RocksDB) st return hex.EncodeToString([]byte{byte(len(h) + 1)}) + h } -func bigintToHex(i *big.Int, d *RocksDB) string { - b := make([]byte, d.chainParser.MaxPackedBigintBytes()) - l := d.chainParser.PackBigint(i, b) - return hex.EncodeToString(b[:l]) -} - -func varintToHex(i int32) string { - b := make([]byte, vlq.MaxLen32) - l := vlq.PutInt(b, int64(i)) +func bigintToHex(i *big.Int) string { + b := make([]byte, maxPackedBigintBytes) + l := packBigint(i, b) return hex.EncodeToString(b[:l]) } @@ -100,7 +127,6 @@ func varuintToHex(i uint) string { return hex.EncodeToString(b[:l]) } - func uintToHex(i uint32) string { buf := make([]byte, 4) binary.BigEndian.PutUint32(buf, i) @@ -116,14 +142,14 @@ func addressKeyHex(a string, height uint32, d *RocksDB) string { return dbtestdata.AddressToPubKeyHex(a, d.chainParser) + uintToHex(^height) } -func txIndexesHex(tx string, indexes []int32, d *RocksDB) string { +func txIndexesHex(tx string, indexes []int32) string { buf := make([]byte, vlq.MaxLen32) for i, index := range indexes { index <<= 1 if i == len(indexes)-1 { index |= 1 } - l := d.chainParser.PackVarint32(index, buf) + l := packVarint32(index, buf) tx += hex.EncodeToString(buf[:l]) } return tx @@ -159,10 +185,10 @@ func checkColumn(d *RocksDB, col int, kp []keyPair) error { defer it.Close() i := 0 for it.SeekToFirst(); it.Valid(); it.Next() { + key := hex.EncodeToString(it.Key().Data()) if i >= len(kp) { - return errors.Errorf("Expected less rows in column %v", cfNames[col]) + return errors.Errorf("Expected less rows in column %v, superfluous key %v", cfNames[col], key) } - key := hex.EncodeToString(it.Key().Data()) if key != kp[i].Key { return errors.Errorf("Incorrect key %v found in column %v row %v, expecting %v", key, cfNames[col], i, kp[i].Key) } @@ -198,15 +224,15 @@ func verifyAfterBitcoinTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect bool } // the vout is encoded as signed varint, i.e. value * 2 for non negative values if err := checkColumn(d, cfAddresses, []keyPair{ - {addressKeyHex(dbtestdata.Addr1, 225493, d), txIndexesHex(dbtestdata.TxidB1T1, []int32{0}, d), nil}, - {addressKeyHex(dbtestdata.Addr2, 225493, d), txIndexesHex(dbtestdata.TxidB1T1, []int32{1, 2}, d), nil}, - {addressKeyHex(dbtestdata.Addr3, 225493, d), txIndexesHex(dbtestdata.TxidB1T2, []int32{0}, d), nil}, - {addressKeyHex(dbtestdata.Addr4, 225493, d), txIndexesHex(dbtestdata.TxidB1T2, []int32{1}, d), nil}, - {addressKeyHex(dbtestdata.Addr5, 225493, d), txIndexesHex(dbtestdata.TxidB1T2, []int32{2}, d), nil}, + {addressKeyHex(dbtestdata.Addr1, 225493, d), txIndexesHex(dbtestdata.TxidB1T1, []int32{0}), nil}, + {addressKeyHex(dbtestdata.Addr2, 225493, d), txIndexesHex(dbtestdata.TxidB1T1, []int32{1, 2}), nil}, + {addressKeyHex(dbtestdata.Addr3, 225493, d), txIndexesHex(dbtestdata.TxidB1T2, []int32{0}), nil}, + {addressKeyHex(dbtestdata.Addr4, 225493, d), txIndexesHex(dbtestdata.TxidB1T2, []int32{1}), nil}, + {addressKeyHex(dbtestdata.Addr5, 225493, d), txIndexesHex(dbtestdata.TxidB1T2, []int32{2}), nil}, }); err != nil { { t.Fatal(err) - } + } } if err := checkColumn(d, cfTxAddresses, []keyPair{ { @@ -214,9 +240,9 @@ func verifyAfterBitcoinTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect bool varuintToHex(225493) + "00" + "03" + - addressToPubKeyHexWithLength(dbtestdata.Addr1, t, d) + bigintToHex(dbtestdata.SatB1T1A1, d) + - addressToPubKeyHexWithLength(dbtestdata.Addr2, t, d) + bigintToHex(dbtestdata.SatB1T1A2, d) + - addressToPubKeyHexWithLength(dbtestdata.Addr2, t, d) + bigintToHex(dbtestdata.SatB1T1A2, d), + addressToPubKeyHexWithLength(dbtestdata.Addr1, t, d) + bigintToHex(dbtestdata.SatB1T1A1) + + addressToPubKeyHexWithLength(dbtestdata.Addr2, t, d) + bigintToHex(dbtestdata.SatB1T1A2) + + addressToPubKeyHexWithLength(dbtestdata.Addr2, t, d) + bigintToHex(dbtestdata.SatB1T1A2), nil, }, { @@ -224,9 +250,9 @@ func verifyAfterBitcoinTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect bool varuintToHex(225493) + "00" + "03" + - addressToPubKeyHexWithLength(dbtestdata.Addr3, t, d) + bigintToHex(dbtestdata.SatB1T2A3, d) + - addressToPubKeyHexWithLength(dbtestdata.Addr4, t, d) + bigintToHex(dbtestdata.SatB1T2A4, d) + - addressToPubKeyHexWithLength(dbtestdata.Addr5, t, d) + bigintToHex(dbtestdata.SatB1T2A5, d), + addressToPubKeyHexWithLength(dbtestdata.Addr3, t, d) + bigintToHex(dbtestdata.SatB1T2A3) + + addressToPubKeyHexWithLength(dbtestdata.Addr4, t, d) + bigintToHex(dbtestdata.SatB1T2A4) + + addressToPubKeyHexWithLength(dbtestdata.Addr5, t, d) + bigintToHex(dbtestdata.SatB1T2A5), nil, }, }); err != nil { @@ -237,33 +263,33 @@ func verifyAfterBitcoinTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect bool if err := checkColumn(d, cfAddressBalance, []keyPair{ { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr1, d.chainParser), - "01" + bigintToHex(dbtestdata.SatZero, d) + bigintToHex(dbtestdata.SatB1T1A1, d) + - dbtestdata.TxidB1T1 + varuintToHex(0) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T1A1, d), + "01" + bigintToHex(dbtestdata.SatZero) + bigintToHex(dbtestdata.SatB1T1A1) + + dbtestdata.TxidB1T1 + varuintToHex(0) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T1A1), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr2, d.chainParser), - "01" + bigintToHex(dbtestdata.SatZero, d) + bigintToHex(dbtestdata.SatB1T1A2Double, d) + - dbtestdata.TxidB1T1 + varuintToHex(1) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T1A2, d) + - dbtestdata.TxidB1T1 + varuintToHex(2) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T1A2, d), + "01" + bigintToHex(dbtestdata.SatZero) + bigintToHex(dbtestdata.SatB1T1A2Double) + + dbtestdata.TxidB1T1 + varuintToHex(1) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T1A2) + + dbtestdata.TxidB1T1 + varuintToHex(2) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T1A2), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr3, d.chainParser), - "01" + bigintToHex(dbtestdata.SatZero, d) + bigintToHex(dbtestdata.SatB1T2A3, d) + - dbtestdata.TxidB1T2 + varuintToHex(0) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T2A3, d), + "01" + bigintToHex(dbtestdata.SatZero) + bigintToHex(dbtestdata.SatB1T2A3) + + dbtestdata.TxidB1T2 + varuintToHex(0) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T2A3), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr4, d.chainParser), - "01" + bigintToHex(dbtestdata.SatZero, d) + bigintToHex(dbtestdata.SatB1T2A4, d) + - dbtestdata.TxidB1T2 + varuintToHex(1) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T2A4, d), + "01" + bigintToHex(dbtestdata.SatZero) + bigintToHex(dbtestdata.SatB1T2A4) + + dbtestdata.TxidB1T2 + varuintToHex(1) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T2A4), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr5, d.chainParser), - "01" + bigintToHex(dbtestdata.SatZero, d) + bigintToHex(dbtestdata.SatB1T2A5, d) + - dbtestdata.TxidB1T2 + varuintToHex(2) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T2A5, d), + "01" + bigintToHex(dbtestdata.SatZero) + bigintToHex(dbtestdata.SatB1T2A5) + + dbtestdata.TxidB1T2 + varuintToHex(2) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T2A5), nil, }, }); err != nil { @@ -279,7 +305,7 @@ func verifyAfterBitcoinTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect bool blockTxsKp = []keyPair{ { "000370d5", - dbtestdata.TxidB1T1 + "00" + dbtestdata.TxidB1T2 + varintToHex(0), + dbtestdata.TxidB1T1 + "00" + dbtestdata.TxidB1T2 + "00", nil, }, } @@ -310,20 +336,20 @@ func verifyAfterBitcoinTypeBlock2(t *testing.T, d *RocksDB) { } } if err := checkColumn(d, cfAddresses, []keyPair{ - {addressKeyHex(dbtestdata.Addr1, 225493, d), txIndexesHex(dbtestdata.TxidB1T1, []int32{0}, d), nil}, - {addressKeyHex(dbtestdata.Addr2, 225493, d), txIndexesHex(dbtestdata.TxidB1T1, []int32{1, 2}, d), nil}, - {addressKeyHex(dbtestdata.Addr3, 225493, d), txIndexesHex(dbtestdata.TxidB1T2, []int32{0}, d), nil}, - {addressKeyHex(dbtestdata.Addr4, 225493, d), txIndexesHex(dbtestdata.TxidB1T2, []int32{1}, d), nil}, - {addressKeyHex(dbtestdata.Addr5, 225493, d), txIndexesHex(dbtestdata.TxidB1T2, []int32{2}, d), nil}, - {addressKeyHex(dbtestdata.Addr6, 225494, d), txIndexesHex(dbtestdata.TxidB2T2, []int32{^0}, d) + txIndexesHex(dbtestdata.TxidB2T1, []int32{0}, d), nil}, - {addressKeyHex(dbtestdata.Addr7, 225494, d), txIndexesHex(dbtestdata.TxidB2T1, []int32{1}, d), nil}, - {addressKeyHex(dbtestdata.Addr8, 225494, d), txIndexesHex(dbtestdata.TxidB2T2, []int32{0}, d), nil}, - {addressKeyHex(dbtestdata.Addr9, 225494, d), txIndexesHex(dbtestdata.TxidB2T2, []int32{1}, d), nil}, - {addressKeyHex(dbtestdata.Addr3, 225494, d), txIndexesHex(dbtestdata.TxidB2T1, []int32{^0}, d), nil}, - {addressKeyHex(dbtestdata.Addr2, 225494, d), txIndexesHex(dbtestdata.TxidB2T1, []int32{^1}, d), nil}, - {addressKeyHex(dbtestdata.Addr5, 225494, d), txIndexesHex(dbtestdata.TxidB2T3, []int32{0, ^0}, d), nil}, - {addressKeyHex(dbtestdata.AddrA, 225494, d), txIndexesHex(dbtestdata.TxidB2T4, []int32{0}, d), nil}, - {addressKeyHex(dbtestdata.Addr4, 225494, d), txIndexesHex(dbtestdata.TxidB2T2, []int32{^1}, d), nil}, + {addressKeyHex(dbtestdata.Addr1, 225493, d), txIndexesHex(dbtestdata.TxidB1T1, []int32{0}), nil}, + {addressKeyHex(dbtestdata.Addr2, 225493, d), txIndexesHex(dbtestdata.TxidB1T1, []int32{1, 2}), nil}, + {addressKeyHex(dbtestdata.Addr3, 225493, d), txIndexesHex(dbtestdata.TxidB1T2, []int32{0}), nil}, + {addressKeyHex(dbtestdata.Addr4, 225493, d), txIndexesHex(dbtestdata.TxidB1T2, []int32{1}), nil}, + {addressKeyHex(dbtestdata.Addr5, 225493, d), txIndexesHex(dbtestdata.TxidB1T2, []int32{2}), nil}, + {addressKeyHex(dbtestdata.Addr6, 225494, d), txIndexesHex(dbtestdata.TxidB2T2, []int32{^0}) + txIndexesHex(dbtestdata.TxidB2T1, []int32{0}), nil}, + {addressKeyHex(dbtestdata.Addr7, 225494, d), txIndexesHex(dbtestdata.TxidB2T1, []int32{1}), nil}, + {addressKeyHex(dbtestdata.Addr8, 225494, d), txIndexesHex(dbtestdata.TxidB2T2, []int32{0}), nil}, + {addressKeyHex(dbtestdata.Addr9, 225494, d), txIndexesHex(dbtestdata.TxidB2T2, []int32{1}), nil}, + {addressKeyHex(dbtestdata.Addr3, 225494, d), txIndexesHex(dbtestdata.TxidB2T1, []int32{^0}), nil}, + {addressKeyHex(dbtestdata.Addr2, 225494, d), txIndexesHex(dbtestdata.TxidB2T1, []int32{^1}), nil}, + {addressKeyHex(dbtestdata.Addr5, 225494, d), txIndexesHex(dbtestdata.TxidB2T3, []int32{0, ^0}), nil}, + {addressKeyHex(dbtestdata.AddrA, 225494, d), txIndexesHex(dbtestdata.TxidB2T4, []int32{0}), nil}, + {addressKeyHex(dbtestdata.Addr4, 225494, d), txIndexesHex(dbtestdata.TxidB2T2, []int32{^1}), nil}, }); err != nil { { t.Fatal(err) @@ -335,9 +361,9 @@ func verifyAfterBitcoinTypeBlock2(t *testing.T, d *RocksDB) { varuintToHex(225493) + "00" + "03" + - addressToPubKeyHexWithLength(dbtestdata.Addr1, t, d) + bigintToHex(dbtestdata.SatB1T1A1, d) + - spentAddressToPubKeyHexWithLength(dbtestdata.Addr2, t, d) + bigintToHex(dbtestdata.SatB1T1A2, d) + - addressToPubKeyHexWithLength(dbtestdata.Addr2, t, d) + bigintToHex(dbtestdata.SatB1T1A2, d), + addressToPubKeyHexWithLength(dbtestdata.Addr1, t, d) + bigintToHex(dbtestdata.SatB1T1A1) + + spentAddressToPubKeyHexWithLength(dbtestdata.Addr2, t, d) + bigintToHex(dbtestdata.SatB1T1A2) + + addressToPubKeyHexWithLength(dbtestdata.Addr2, t, d) + bigintToHex(dbtestdata.SatB1T1A2), nil, }, { @@ -345,50 +371,50 @@ func verifyAfterBitcoinTypeBlock2(t *testing.T, d *RocksDB) { varuintToHex(225493) + "00" + "03" + - spentAddressToPubKeyHexWithLength(dbtestdata.Addr3, t, d) + bigintToHex(dbtestdata.SatB1T2A3, d) + - spentAddressToPubKeyHexWithLength(dbtestdata.Addr4, t, d) + bigintToHex(dbtestdata.SatB1T2A4, d) + - spentAddressToPubKeyHexWithLength(dbtestdata.Addr5, t, d) + bigintToHex(dbtestdata.SatB1T2A5, d), + spentAddressToPubKeyHexWithLength(dbtestdata.Addr3, t, d) + bigintToHex(dbtestdata.SatB1T2A3) + + spentAddressToPubKeyHexWithLength(dbtestdata.Addr4, t, d) + bigintToHex(dbtestdata.SatB1T2A4) + + spentAddressToPubKeyHexWithLength(dbtestdata.Addr5, t, d) + bigintToHex(dbtestdata.SatB1T2A5), nil, }, { dbtestdata.TxidB2T1, varuintToHex(225494) + "02" + - inputAddressToPubKeyHexWithLength(dbtestdata.Addr3, t, d) + bigintToHex(dbtestdata.SatB1T2A3, d) + - inputAddressToPubKeyHexWithLength(dbtestdata.Addr2, t, d) + bigintToHex(dbtestdata.SatB1T1A2, d) + + inputAddressToPubKeyHexWithLength(dbtestdata.Addr3, t, d) + bigintToHex(dbtestdata.SatB1T2A3) + + inputAddressToPubKeyHexWithLength(dbtestdata.Addr2, t, d) + bigintToHex(dbtestdata.SatB1T1A2) + "03" + - spentAddressToPubKeyHexWithLength(dbtestdata.Addr6, t, d) + bigintToHex(dbtestdata.SatB2T1A6, d) + - addressToPubKeyHexWithLength(dbtestdata.Addr7, t, d) + bigintToHex(dbtestdata.SatB2T1A7, d) + - hex.EncodeToString([]byte{byte(len(dbtestdata.TxidB2T1Output3OpReturn))}) + dbtestdata.TxidB2T1Output3OpReturn + bigintToHex(dbtestdata.SatZero, d), + spentAddressToPubKeyHexWithLength(dbtestdata.Addr6, t, d) + bigintToHex(dbtestdata.SatB2T1A6) + + addressToPubKeyHexWithLength(dbtestdata.Addr7, t, d) + bigintToHex(dbtestdata.SatB2T1A7) + + hex.EncodeToString([]byte{byte(len(dbtestdata.TxidB2T1Output3OpReturn))}) + dbtestdata.TxidB2T1Output3OpReturn + bigintToHex(dbtestdata.SatZero), nil, }, { dbtestdata.TxidB2T2, varuintToHex(225494) + "02" + - inputAddressToPubKeyHexWithLength(dbtestdata.Addr6, t, d) + bigintToHex(dbtestdata.SatB2T1A6, d) + - inputAddressToPubKeyHexWithLength(dbtestdata.Addr4, t, d) + bigintToHex(dbtestdata.SatB1T2A4, d) + + inputAddressToPubKeyHexWithLength(dbtestdata.Addr6, t, d) + bigintToHex(dbtestdata.SatB2T1A6) + + inputAddressToPubKeyHexWithLength(dbtestdata.Addr4, t, d) + bigintToHex(dbtestdata.SatB1T2A4) + "02" + - addressToPubKeyHexWithLength(dbtestdata.Addr8, t, d) + bigintToHex(dbtestdata.SatB2T2A8, d) + - addressToPubKeyHexWithLength(dbtestdata.Addr9, t, d) + bigintToHex(dbtestdata.SatB2T2A9, d), + addressToPubKeyHexWithLength(dbtestdata.Addr8, t, d) + bigintToHex(dbtestdata.SatB2T2A8) + + addressToPubKeyHexWithLength(dbtestdata.Addr9, t, d) + bigintToHex(dbtestdata.SatB2T2A9), nil, }, { dbtestdata.TxidB2T3, varuintToHex(225494) + "01" + - inputAddressToPubKeyHexWithLength(dbtestdata.Addr5, t, d) + bigintToHex(dbtestdata.SatB1T2A5, d) + + inputAddressToPubKeyHexWithLength(dbtestdata.Addr5, t, d) + bigintToHex(dbtestdata.SatB1T2A5) + "01" + - addressToPubKeyHexWithLength(dbtestdata.Addr5, t, d) + bigintToHex(dbtestdata.SatB2T3A5, d), + addressToPubKeyHexWithLength(dbtestdata.Addr5, t, d) + bigintToHex(dbtestdata.SatB2T3A5), nil, }, { dbtestdata.TxidB2T4, varuintToHex(225494) + - "01" + inputAddressToPubKeyHexWithLength("", t, d) + bigintToHex(dbtestdata.SatZero, d) + + "01" + inputAddressToPubKeyHexWithLength("", t, d) + bigintToHex(dbtestdata.SatZero) + "02" + - addressToPubKeyHexWithLength(dbtestdata.AddrA, t, d) + bigintToHex(dbtestdata.SatB2T4AA, d) + - addressToPubKeyHexWithLength("", t, d) + bigintToHex(dbtestdata.SatZero, d), + addressToPubKeyHexWithLength(dbtestdata.AddrA, t, d) + bigintToHex(dbtestdata.SatB2T4AA) + + addressToPubKeyHexWithLength("", t, d) + bigintToHex(dbtestdata.SatZero), nil, }, }); err != nil { @@ -399,59 +425,59 @@ func verifyAfterBitcoinTypeBlock2(t *testing.T, d *RocksDB) { if err := checkColumn(d, cfAddressBalance, []keyPair{ { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr1, d.chainParser), - "01" + bigintToHex(dbtestdata.SatZero, d) + bigintToHex(dbtestdata.SatB1T1A1, d) + - dbtestdata.TxidB1T1 + varuintToHex(0) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T1A1, d), + "01" + bigintToHex(dbtestdata.SatZero) + bigintToHex(dbtestdata.SatB1T1A1) + + dbtestdata.TxidB1T1 + varuintToHex(0) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T1A1), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr2, d.chainParser), - "02" + bigintToHex(dbtestdata.SatB1T1A2, d) + bigintToHex(dbtestdata.SatB1T1A2, d) + - dbtestdata.TxidB1T1 + varuintToHex(2) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T1A2, d), + "02" + bigintToHex(dbtestdata.SatB1T1A2) + bigintToHex(dbtestdata.SatB1T1A2) + + dbtestdata.TxidB1T1 + varuintToHex(2) + varuintToHex(225493) + bigintToHex(dbtestdata.SatB1T1A2), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr3, d.chainParser), - "02" + bigintToHex(dbtestdata.SatB1T2A3, d) + bigintToHex(dbtestdata.SatZero, d), + "02" + bigintToHex(dbtestdata.SatB1T2A3) + bigintToHex(dbtestdata.SatZero), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr4, d.chainParser), - "02" + bigintToHex(dbtestdata.SatB1T2A4, d) + bigintToHex(dbtestdata.SatZero, d), + "02" + bigintToHex(dbtestdata.SatB1T2A4) + bigintToHex(dbtestdata.SatZero), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr5, d.chainParser), - "02" + bigintToHex(dbtestdata.SatB1T2A5, d) + bigintToHex(dbtestdata.SatB2T3A5, d) + - dbtestdata.TxidB2T3 + varuintToHex(0) + varuintToHex(225494) + bigintToHex(dbtestdata.SatB2T3A5, d), + "02" + bigintToHex(dbtestdata.SatB1T2A5) + bigintToHex(dbtestdata.SatB2T3A5) + + dbtestdata.TxidB2T3 + varuintToHex(0) + varuintToHex(225494) + bigintToHex(dbtestdata.SatB2T3A5), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr6, d.chainParser), - "02" + bigintToHex(dbtestdata.SatB2T1A6, d) + bigintToHex(dbtestdata.SatZero, d), + "02" + bigintToHex(dbtestdata.SatB2T1A6) + bigintToHex(dbtestdata.SatZero), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr7, d.chainParser), - "01" + bigintToHex(dbtestdata.SatZero, d) + bigintToHex(dbtestdata.SatB2T1A7, d) + - dbtestdata.TxidB2T1 + varuintToHex(1) + varuintToHex(225494) + bigintToHex(dbtestdata.SatB2T1A7, d), + "01" + bigintToHex(dbtestdata.SatZero) + bigintToHex(dbtestdata.SatB2T1A7) + + dbtestdata.TxidB2T1 + varuintToHex(1) + varuintToHex(225494) + bigintToHex(dbtestdata.SatB2T1A7), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr8, d.chainParser), - "01" + bigintToHex(dbtestdata.SatZero, d) + bigintToHex(dbtestdata.SatB2T2A8, d) + - dbtestdata.TxidB2T2 + varuintToHex(0) + varuintToHex(225494) + bigintToHex(dbtestdata.SatB2T2A8, d), + "01" + bigintToHex(dbtestdata.SatZero) + bigintToHex(dbtestdata.SatB2T2A8) + + dbtestdata.TxidB2T2 + varuintToHex(0) + varuintToHex(225494) + bigintToHex(dbtestdata.SatB2T2A8), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.Addr9, d.chainParser), - "01" + bigintToHex(dbtestdata.SatZero, d) + bigintToHex(dbtestdata.SatB2T2A9, d) + - dbtestdata.TxidB2T2 + varuintToHex(1) + varuintToHex(225494) + bigintToHex(dbtestdata.SatB2T2A9, d), + "01" + bigintToHex(dbtestdata.SatZero) + bigintToHex(dbtestdata.SatB2T2A9) + + dbtestdata.TxidB2T2 + varuintToHex(1) + varuintToHex(225494) + bigintToHex(dbtestdata.SatB2T2A9), nil, }, { dbtestdata.AddressToPubKeyHex(dbtestdata.AddrA, d.chainParser), - "01" + bigintToHex(dbtestdata.SatZero, d) + bigintToHex(dbtestdata.SatB2T4AA, d) + - dbtestdata.TxidB2T4 + varuintToHex(0) + varuintToHex(225494) + bigintToHex(dbtestdata.SatB2T4AA, d), + "01" + bigintToHex(dbtestdata.SatZero) + bigintToHex(dbtestdata.SatB2T4AA) + + dbtestdata.TxidB2T4 + varuintToHex(0) + varuintToHex(225494) + bigintToHex(dbtestdata.SatB2T4AA), nil, }, }); err != nil { @@ -462,10 +488,10 @@ func verifyAfterBitcoinTypeBlock2(t *testing.T, d *RocksDB) { if err := checkColumn(d, cfBlockTxs, []keyPair{ { "000370d6", - dbtestdata.TxidB2T1 + "02" + dbtestdata.TxidB1T2 + "00" + dbtestdata.TxidB1T1 + varintToHex(1) + - dbtestdata.TxidB2T2 + "02" + dbtestdata.TxidB2T1 + "00" + dbtestdata.TxidB1T2 + varintToHex(1) + - dbtestdata.TxidB2T3 + "01" + dbtestdata.TxidB1T2 + varintToHex(2) + - dbtestdata.TxidB2T4 + "01" + "0000000000000000000000000000000000000000000000000000000000000000" + varintToHex(0), + dbtestdata.TxidB2T1 + "02" + dbtestdata.TxidB1T2 + "00" + dbtestdata.TxidB1T1 + "02" + + dbtestdata.TxidB2T2 + "02" + dbtestdata.TxidB2T1 + "00" + dbtestdata.TxidB1T2 + "02" + + dbtestdata.TxidB2T3 + "01" + dbtestdata.TxidB1T2 + "04" + + dbtestdata.TxidB2T4 + "01" + "0000000000000000000000000000000000000000000000000000000000000000" + "00", nil, }, }); err != nil { @@ -482,7 +508,7 @@ type txidIndex struct { func verifyGetTransactions(t *testing.T, d *RocksDB, addr string, low, high uint32, wantTxids []txidIndex, wantErr error) { gotTxids := make([]txidIndex, 0) - addToTxids := func(txid string, height uint32, assetGuid []uint64, indexes []int32) error { + addToTxids := func(txid string, height uint32, indexes []int32) error { for _, index := range indexes { gotTxids = append(gotTxids, txidIndex{txid, index}) } @@ -522,7 +548,7 @@ func testTxCache(t *testing.T, d *RocksDB, b *bchain.Block, tx *bchain.Tx) { // Confirmations are not stored in the DB, set them from input tx gtx.Confirmations = tx.Confirmations if !reflect.DeepEqual(gtx, tx) { - t.Errorf("GetTx: %v, want %v", gtx, tx) + t.Errorf("GetTx: %+v, want %+v", gtx, tx) } if err := d.DeleteTx(tx.Txid); err != nil { t.Fatal(err) @@ -555,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 @@ -566,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 @@ -591,11 +617,6 @@ func TestRocksDB_Index_BitcoinType(t *testing.T) { {dbtestdata.TxidB2T2, ^0}, {dbtestdata.TxidB2T1, 0}, }, nil) - verifyGetTransactions(t, d, dbtestdata.Addr5, 0, 1000000, []txidIndex{ - {dbtestdata.TxidB2T3, 0}, - {dbtestdata.TxidB2T3, ^0}, - {dbtestdata.TxidB1T2, 2}, - }, nil) verifyGetTransactions(t, d, "mtGXQvBowMkBpnhLckhxhbwYK44Gs9eBad", 500000, 1000000, []txidIndex{}, errors.New("checksum mismatch")) // GetBestBlock @@ -633,7 +654,7 @@ func TestRocksDB_Index_BitcoinType(t *testing.T) { if err != nil { t.Fatal(err) } - iw := &bchain.DbBlockInfo{ + iw := &BlockInfo{ Hash: "00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6", Txs: 4, Size: 2345678, @@ -680,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 @@ -690,20 +711,20 @@ 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 - ab, err := d.GetAddressBalance(dbtestdata.Addr5, bchain.AddressBalanceDetailUTXO) + ab, err := d.GetAddressBalance(dbtestdata.Addr5, AddressBalanceDetailUTXO) if err != nil { t.Fatal(err) } - abw := &bchain.AddrBalance{ + abw := &AddrBalance{ Txs: 2, SentSat: *dbtestdata.SatB1T2A5, BalanceSat: *dbtestdata.SatB2T3A5, - Utxos: []bchain.Utxo{ + Utxos: []Utxo{ { BtxID: hexToBytes(dbtestdata.TxidB2T3), Vout: 0, @@ -725,9 +746,9 @@ func TestRocksDB_Index_BitcoinType(t *testing.T) { if err != nil { t.Fatal(err) } - taw := &bchain.TxAddresses{ + taw := &TxAddresses{ Height: 225494, - Inputs: []bchain.TxInput{ + Inputs: []TxInput{ { AddrDesc: addressToAddrDesc(dbtestdata.Addr3, d.chainParser), ValueSat: *dbtestdata.SatB1T2A3, @@ -737,7 +758,7 @@ func TestRocksDB_Index_BitcoinType(t *testing.T) { ValueSat: *dbtestdata.SatB1T1A2, }, }, - Outputs: []bchain.TxOutput{ + Outputs: []TxOutput{ { AddrDesc: addressToAddrDesc(dbtestdata.Addr6, d.chainParser), Spent: true, @@ -803,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") @@ -815,18 +837,53 @@ func Test_BulkConnect_BitcoinType(t *testing.T) { } } -func Test_packBigint_unpackBigint(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) bigbigbig := big.NewInt(0) bigbigbig.Mul(bigbig2, bigbig2) bigbigbig.Mul(bigbigbig, bigbigbig) bigbigbig.Mul(bigbigbig, bigbigbig) - maxPackedBigintBytes := d.chainParser.MaxPackedBigintBytes() tests := []struct { name string bi *big.Int @@ -877,33 +934,33 @@ func Test_packBigint_unpackBigint(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // PackBigint - got := d.chainParser.PackBigint(tt.bi, tt.buf) + // packBigint + got := packBigint(tt.bi, tt.buf) if tt.toobiglen == 0 { // create buffer that we expect bb := tt.bi.Bytes() want := append([]byte(nil), byte(len(bb))) want = append(want, bb...) if got != len(want) { - t.Errorf("PackBigint() = %v, want %v", got, len(want)) + t.Errorf("packBigint() = %v, want %v", got, len(want)) } for i := 0; i < got; i++ { if tt.buf[i] != want[i] { - t.Errorf("PackBigint() buf = %v, want %v", tt.buf[:got], want) + t.Errorf("packBigint() buf = %v, want %v", tt.buf[:got], want) break } } - // UnpackBigint - got1, got2 := d.chainParser.UnpackBigint(tt.buf) + // unpackBigint + got1, got2 := unpackBigint(tt.buf) if got2 != len(want) { - t.Errorf("UnpackBigint() = %v, want %v", got2, len(want)) + t.Errorf("unpackBigint() = %v, want %v", got2, len(want)) } if tt.bi.Cmp(&got1) != 0 { - t.Errorf("UnpackBigint() = %v, want %v", got1, tt.bi) + t.Errorf("unpackBigint() = %v, want %v", got1, tt.bi) } } else { if got != tt.toobiglen { - t.Errorf("PackBigint() = %v, want toobiglen %v", got, tt.toobiglen) + t.Errorf("packBigint() = %v, want toobiglen %v", got, tt.toobiglen) } } }) @@ -921,16 +978,17 @@ func addressToAddrDesc(addr string, parser bchain.BlockChainParser) []byte { func Test_packTxAddresses_unpackTxAddresses(t *testing.T) { parser := bitcoinTestnetParser() tests := []struct { - name string - hex string - data *bchain.TxAddresses + name string + hex string + data *TxAddresses + rocksDB *RocksDB }{ { name: "1", hex: "7b0216001443aac20a116e09ea4f7914be1c55e4c17aa600b70016001454633aa8bd2e552bd4e89c01e73c1b7905eb58460811207cb68a199872012d001443aac20a116e09ea4f7914be1c55e4c17aa600b70101", - data: &bchain.TxAddresses{ + data: &TxAddresses{ Height: 123, - Inputs: []bchain.TxInput{ + Inputs: []TxInput{ { AddrDesc: addressToAddrDesc("tb1qgw4vyzs3dcy75nmezjlpc40yc9a2vq9hghdyt2", parser), ValueSat: *big.NewInt(0), @@ -940,7 +998,7 @@ func Test_packTxAddresses_unpackTxAddresses(t *testing.T) { ValueSat: *big.NewInt(1234123421342341234), }, }, - Outputs: []bchain.TxOutput{ + Outputs: []TxOutput{ { AddrDesc: addressToAddrDesc("tb1qgw4vyzs3dcy75nmezjlpc40yc9a2vq9hghdyt2", parser), ValueSat: *big.NewInt(1), @@ -948,13 +1006,14 @@ func Test_packTxAddresses_unpackTxAddresses(t *testing.T) { }, }, }, + rocksDB: &RocksDB{chainParser: parser, extendedIndex: false}, }, { name: "2", hex: "e0390317a9149eb21980dc9d413d8eac27314938b9da920ee53e8705021918f2c017a91409f70b896169c37981d2b54b371df0d81a136a2c870501dd7e28c017a914e371782582a4addb541362c55565d2cdf56f6498870501a1e35ec0052fa9141d9ca71efa36d814424ea6ca1437e67287aebe348705012aadcac02ea91424fbc77cdc62702ade74dcf989c15e5d3f9240bc870501664894c02fa914afbfb74ee994c7d45f6698738bc4226d065266f7870501a1e35ec03276a914d2a37ce20ac9ec4f15dd05a7c6e8e9fbdb99850e88ac043b9943603376a9146b2044146a4438e6e5bfbc65f147afeb64d14fbb88ac05012a05f200", - data: &bchain.TxAddresses{ + data: &TxAddresses{ Height: 12345, - Inputs: []bchain.TxInput{ + Inputs: []TxInput{ { AddrDesc: addressToAddrDesc("2N7iL7AvS4LViugwsdjTB13uN4T7XhV1bCP", parser), ValueSat: *big.NewInt(9011000000), @@ -968,7 +1027,7 @@ func Test_packTxAddresses_unpackTxAddresses(t *testing.T) { ValueSat: *big.NewInt(7011000000), }, }, - Outputs: []bchain.TxOutput{ + Outputs: []TxOutput{ { AddrDesc: addressToAddrDesc("2MuwoFGwABMakU7DCpdGDAKzyj2nTyRagDP", parser), ValueSat: *big.NewInt(5011000000), @@ -994,19 +1053,20 @@ func Test_packTxAddresses_unpackTxAddresses(t *testing.T) { }, }, }, + rocksDB: &RocksDB{chainParser: parser, extendedIndex: false}, }, { name: "empty address", hex: "baef9a1501000204d2020002162e010162", - data: &bchain.TxAddresses{ + data: &TxAddresses{ Height: 123456789, - Inputs: []bchain.TxInput{ + Inputs: []TxInput{ { AddrDesc: []byte(nil), ValueSat: *big.NewInt(1234), }, }, - Outputs: []bchain.TxOutput{ + Outputs: []TxOutput{ { AddrDesc: []byte(nil), ValueSat: *big.NewInt(5678), @@ -1018,32 +1078,126 @@ func Test_packTxAddresses_unpackTxAddresses(t *testing.T) { }, }, }, + rocksDB: &RocksDB{chainParser: parser, extendedIndex: false}, }, { name: "empty", hex: "000000", - data: &bchain.TxAddresses{ - Inputs: []bchain.TxInput{}, - Outputs: []bchain.TxOutput{}, + data: &TxAddresses{ + 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, parser.MaxPackedBigintBytes()) + varBuf := make([]byte, maxPackedBigintBytes) buf := make([]byte, 1024) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - b := parser.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) + t.Errorf("packTxAddresses() = %v, want %v", hex, tt.hex) } - got1, err := parser.UnpackTxAddresses(b) + got1, err := tt.rocksDB.unpackTxAddresses(b) if err != nil { - t.Errorf("UnpackTxAddresses() error = %v", err) + t.Errorf("unpackTxAddresses() error = %v", err) return } if !reflect.DeepEqual(got1, tt.data) { - t.Errorf("UnpackTxAddresses() = %+v, want %+v", got1, tt.data) + t.Errorf("unpackTxAddresses() = %+v, want %+v", got1, tt.data) } }) } @@ -1054,26 +1208,26 @@ func Test_packAddrBalance_unpackAddrBalance(t *testing.T) { tests := []struct { name string hex string - data *bchain.AddrBalance + data *AddrBalance }{ { name: "no utxos", hex: "7b060b44cc1af8520514faf980ac", - data: &bchain.AddrBalance{ + data: &AddrBalance{ BalanceSat: *big.NewInt(90110001324), SentSat: *big.NewInt(12390110001234), Txs: 123, - Utxos: []bchain.Utxo{}, + Utxos: []Utxo{}, }, }, { name: "utxos", hex: "7b060b44cc1af8520514faf980ac00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa38400c87c440060b2fd12177a6effd9ef509383d536b1c8af5bf434c8efbf521a4f2befd4022bbd68694b4ac750098faf659010105e2e48aeabdd9b75def7b48d756ba304713c2aba7b522bf9dbc893fc4231b0782c6df6d84ccd88552087e9cba87a275ffff", - data: &bchain.AddrBalance{ + data: &AddrBalance{ BalanceSat: *big.NewInt(90110001324), SentSat: *big.NewInt(12390110001234), Txs: 123, - Utxos: []bchain.Utxo{ + Utxos: []Utxo{ { BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 12, @@ -1098,73 +1252,73 @@ func Test_packAddrBalance_unpackAddrBalance(t *testing.T) { { name: "empty", hex: "000000", - data: &bchain.AddrBalance{ - Utxos: []bchain.Utxo{}, + data: &AddrBalance{ + Utxos: []Utxo{}, }, }, } - varBuf := make([]byte, parser.MaxPackedBigintBytes()) + varBuf := make([]byte, maxPackedBigintBytes) buf := make([]byte, 32) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - b := parser.PackAddrBalance(tt.data, buf, varBuf) + b := packAddrBalance(tt.data, buf, varBuf) hex := hex.EncodeToString(b) if !reflect.DeepEqual(hex, tt.hex) { - t.Errorf("PackTxAddresses() = %v, want %v", hex, tt.hex) + t.Errorf("packTxAddresses() = %v, want %v", hex, tt.hex) } - got1, err := parser.UnpackAddrBalance(b, parser.PackedTxidLen(), bchain.AddressBalanceDetailUTXO) + got1, err := unpackAddrBalance(b, parser.PackedTxidLen(), AddressBalanceDetailUTXO) if err != nil { - t.Errorf("UnpackTxAddresses() error = %v", err) + t.Errorf("unpackTxAddresses() error = %v", err) return } if !reflect.DeepEqual(got1, tt.data) { - t.Errorf("UnpackTxAddresses() = %+v, want %+v", got1, tt.data) + t.Errorf("unpackTxAddresses() = %+v, want %+v", got1, tt.data) } }) } } -func createUtxoMap(ab *bchain.AddrBalance) { +func createUtxoMap(ab *AddrBalance) { l := len(ab.Utxos) - ab.UtxosMap = make(map[string]int, 32) + ab.utxosMap = make(map[string]int, 32) for i := 0; i < l; i++ { s := string(ab.Utxos[i].BtxID) - if _, e := ab.UtxosMap[s]; !e { - ab.UtxosMap[s] = i + if _, e := ab.utxosMap[s]; !e { + ab.utxosMap[s] = i } } } func TestAddrBalance_utxo_methods(t *testing.T) { - ab := &bchain.AddrBalance{ + ab := &AddrBalance{ Txs: 10, SentSat: *big.NewInt(10000), BalanceSat: *big.NewInt(1000), } - // AddUtxo - ab.AddUtxo(&bchain.Utxo{ + // addUtxo + ab.addUtxo(&Utxo{ BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 1, Height: 5000, ValueSat: *big.NewInt(100), }) - ab.AddUtxo(&bchain.Utxo{ + ab.addUtxo(&Utxo{ BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 4, Height: 5000, ValueSat: *big.NewInt(100), }) - ab.AddUtxo(&bchain.Utxo{ + ab.addUtxo(&Utxo{ BtxID: hexToBytes(dbtestdata.TxidB1T2), Vout: 0, Height: 5001, ValueSat: *big.NewInt(800), }) - want := &bchain.AddrBalance{ + want := &AddrBalance{ Txs: 10, SentSat: *big.NewInt(10000), BalanceSat: *big.NewInt(1000), - Utxos: []bchain.Utxo{ + Utxos: []Utxo{ { BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 1, @@ -1189,42 +1343,42 @@ func TestAddrBalance_utxo_methods(t *testing.T) { t.Errorf("addUtxo, got %+v, want %+v", ab, want) } - // AddUtxoInDisconnect - ab.AddUtxoInDisconnect(&bchain.Utxo{ + // addUtxoInDisconnect + ab.addUtxoInDisconnect(&Utxo{ BtxID: hexToBytes(dbtestdata.TxidB2T1), Vout: 0, Height: 5003, ValueSat: *big.NewInt(800), }) - ab.AddUtxoInDisconnect(&bchain.Utxo{ + ab.addUtxoInDisconnect(&Utxo{ BtxID: hexToBytes(dbtestdata.TxidB2T1), Vout: 1, Height: 5003, ValueSat: *big.NewInt(800), }) - ab.AddUtxoInDisconnect(&bchain.Utxo{ + ab.addUtxoInDisconnect(&Utxo{ BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 10, Height: 5000, ValueSat: *big.NewInt(100), }) - ab.AddUtxoInDisconnect(&bchain.Utxo{ + ab.addUtxoInDisconnect(&Utxo{ BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 2, Height: 5000, ValueSat: *big.NewInt(100), }) - ab.AddUtxoInDisconnect(&bchain.Utxo{ + ab.addUtxoInDisconnect(&Utxo{ BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 0, Height: 5000, ValueSat: *big.NewInt(100), }) - want = &bchain.AddrBalance{ + want = &AddrBalance{ Txs: 10, SentSat: *big.NewInt(10000), BalanceSat: *big.NewInt(1000), - Utxos: []bchain.Utxo{ + Utxos: []Utxo{ { BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 0, @@ -1276,63 +1430,63 @@ func TestAddrBalance_utxo_methods(t *testing.T) { }, } if !reflect.DeepEqual(ab, want) { - t.Errorf("AddUtxoInDisconnect, got %+v, want %+v", ab, want) + t.Errorf("addUtxoInDisconnect, got %+v, want %+v", ab, want) } - // MarkUtxoAsSpent - ab.MarkUtxoAsSpent(hexToBytes(dbtestdata.TxidB2T1), 0) + // markUtxoAsSpent + ab.markUtxoAsSpent(hexToBytes(dbtestdata.TxidB2T1), 0) want.Utxos[6].Vout = -1 if !reflect.DeepEqual(ab, want) { - t.Errorf("MarkUtxoAsSpent, got %+v, want %+v", ab, want) + t.Errorf("markUtxoAsSpent, got %+v, want %+v", ab, want) } - // addUtxo with UtxosMap + // addUtxo with utxosMap for i := 0; i < 20; i += 2 { - utxo := bchain.Utxo{ + utxo := Utxo{ BtxID: hexToBytes(dbtestdata.TxidB2T2), Vout: int32(i), Height: 5009, ValueSat: *big.NewInt(800), } - ab.AddUtxo(&utxo) + ab.addUtxo(&utxo) want.Utxos = append(want.Utxos, utxo) } createUtxoMap(want) if !reflect.DeepEqual(ab, want) { - t.Errorf("addUtxo with UtxosMap, got %+v, want %+v", ab, want) + t.Errorf("addUtxo with utxosMap, got %+v, want %+v", ab, want) } - // MarkUtxoAsSpent with UtxosMap - ab.MarkUtxoAsSpent(hexToBytes(dbtestdata.TxidB2T1), 1) + // markUtxoAsSpent with utxosMap + ab.markUtxoAsSpent(hexToBytes(dbtestdata.TxidB2T1), 1) want.Utxos[7].Vout = -1 if !reflect.DeepEqual(ab, want) { - t.Errorf("MarkUtxoAsSpent with UtxosMap, got %+v, want %+v", ab, want) + t.Errorf("markUtxoAsSpent with utxosMap, got %+v, want %+v", ab, want) } - // AddUtxoInDisconnect with UtxosMap - ab.AddUtxoInDisconnect(&bchain.Utxo{ + // addUtxoInDisconnect with utxosMap + ab.addUtxoInDisconnect(&Utxo{ BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 3, Height: 5000, ValueSat: *big.NewInt(100), }) - want.Utxos = append(want.Utxos, bchain.Utxo{}) + want.Utxos = append(want.Utxos, Utxo{}) copy(want.Utxos[3+1:], want.Utxos[3:]) - want.Utxos[3] = bchain.Utxo{ + want.Utxos[3] = Utxo{ BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 3, Height: 5000, ValueSat: *big.NewInt(100), } - want.UtxosMap = nil + want.utxosMap = nil if !reflect.DeepEqual(ab, want) { - t.Errorf("AddUtxoInDisconnect with UtxosMap, got %+v, want %+v", ab, want) + t.Errorf("addUtxoInDisconnect with utxosMap, got %+v, want %+v", ab, want) } } func Test_reorderUtxo(t *testing.T) { - utxos := []bchain.Utxo{ + utxos := []Utxo{ { BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 3, @@ -1368,15 +1522,15 @@ func Test_reorderUtxo(t *testing.T) { } tests := []struct { name string - utxos []bchain.Utxo + utxos []Utxo index int - want []bchain.Utxo + want []Utxo }{ { name: "middle", utxos: utxos, index: 4, - want: []bchain.Utxo{ + want: []Utxo{ { BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 3, @@ -1415,7 +1569,7 @@ func Test_reorderUtxo(t *testing.T) { name: "start", utxos: utxos, index: 1, - want: []bchain.Utxo{ + want: []Utxo{ { BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 0, @@ -1454,7 +1608,7 @@ func Test_reorderUtxo(t *testing.T) { name: "end", utxos: utxos, index: 6, - want: []bchain.Utxo{ + want: []Utxo{ { BtxID: hexToBytes(dbtestdata.TxidB1T1), Vout: 0, @@ -1500,78 +1654,96 @@ func Test_reorderUtxo(t *testing.T) { } } -func TestRocksTickers(t *testing.T) { - d := setupRocksDB(t, &testBitcoinParser{ - BitcoinParser: bitcoinTestnetParser(), - }) - 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) - } +func Test_packUnpackString(t *testing.T) { + tests := []struct { + name string + }{ + {name: "ahoj"}, + {name: ""}, + {name: "very long long very long long very long long very long long very long long very long long very long long very long long very long long very long long very long long very long long very long long"}, } - - // 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) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := packString(tt.name) + if got, l := unpackString(buf); !reflect.DeepEqual(got, tt.name) || l != len(buf) { + t.Errorf("Test_packUnpackString() = %v, want %v, len %d, want len %d", got, tt.name, l, len(buf)) + } + }) } +} - // Test storing & finding tickers - key, _ := time.Parse(FiatRatesTimeFormat, "20190627000000") - futureKey, _ := time.Parse(FiatRatesTimeFormat, "20190630000000") - - ts1, _ := time.Parse(FiatRatesTimeFormat, "20190628000000") - ticker1 := &CurrencyRatesTicker{ - Timestamp: &ts1, - Rates: map[string]float64{ - "usd": 20000, - }, +func TestRocksDB_packTxIndexes_unpackTxIndexes(t *testing.T) { + type args struct { + txi []txIndexes } - - ts2, _ := time.Parse(FiatRatesTimeFormat, "20190629000000") - ticker2 := &CurrencyRatesTicker{ - Timestamp: &ts2, - Rates: map[string]float64{ - "usd": 30000, + 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", }, } - err := d.FiatRatesStoreTicker(ticker1) - if err != nil { - t.Errorf("Error storing ticker! %v", err) - } - d.FiatRatesStoreTicker(ticker2) - if err != nil { - t.Errorf("Error storing ticker! %v", err) - } - - ticker, err := d.FiatRatesFindTicker(&key) // should find the closest key (ticker1) - 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) - } - - ticker, err = d.FiatRatesFindLastTicker() // should find the last key (ticker2) - if err != nil { - t.Errorf("TestRocksTickers err: %+v", err) - } else if ticker == nil { - t.Errorf("Ticker not found") - } else if ticker.Timestamp.Format(FiatRatesTimeFormat) != ticker2.Timestamp.Format(FiatRatesTimeFormat) { - t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker1.Timestamp, ticker.Timestamp) + d := &RocksDB{ + chainParser: &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, } - - ticker, err = d.FiatRatesFindTicker(&futureKey) // should not find anything - if err != nil { - t.Errorf("TestRocksTickers err: %+v", err) - } else if ticker != nil { - t.Errorf("Ticker found, but the timestamp is older than the last ticker entry.") + 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 7e3da91966..31d321f443 100644 --- a/db/sync.go +++ b/db/sync.go @@ -1,15 +1,22 @@ package db import ( + "context" + stdErrors "errors" + "io" + "net" "os" + "strconv" + "strings" "sync" "sync/atomic" + "syscall" "time" "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/common" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" ) // SyncWorker is handle to SyncWorker @@ -21,31 +28,128 @@ 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 + // MaxStallDuration caps the wall-clock time a single block fetch may spend in + // the retry loop before yielding errResync. Liveness invariant: since lagging + // probes report "no reorg" and known hashes get retried, a genuinely-behind + // backend or chain-shortening reorg relies on this cap. Must stay > 0 + // (ApplyMissingBlockRetryOverride enforces it). + MaxStallDuration time.Duration +} + +// SyncWorkerConfig bundles optional tuning knobs for SyncWorker. +type SyncWorkerConfig struct { + MissingBlockRetry MissingBlockRetryConfig +} + +// DefaultMissingBlockRetryConfig returns the built-in defaults used when no +// per-chain override is supplied. Exported so blockbook.go can overlay +// optional bchain.MissingBlockRetry fields onto known-good defaults. +func DefaultMissingBlockRetryConfig() MissingBlockRetryConfig { + return MissingBlockRetryConfig{ + RecheckThreshold: 10, + RetryDelay: 1 * time.Second, + TipRecheckThreshold: 3, + MaxStallDuration: 60 * time.Second, + } +} + +func defaultSyncWorkerConfig() SyncWorkerConfig { + return SyncWorkerConfig{ + MissingBlockRetry: DefaultMissingBlockRetryConfig(), + } +} + +// ApplyMissingBlockRetryOverride overlays the optional bchain.MissingBlockRetry +// onto the defaults. Zero / unset wire fields keep their default; explicitly set +// but invalid values (negative, or a TipRecheckThreshold above RecheckThreshold) +// keep the default and log a warning. +func ApplyMissingBlockRetryOverride(o *bchain.MissingBlockRetry) MissingBlockRetryConfig { + cfg := DefaultMissingBlockRetryConfig() + if o == nil { + return cfg + } + apply := func(field string, v int, set func(int)) { + if v == 0 { + return // unset: keep default + } + if v < 0 { + glog.Warningf("sync: missingBlockRetry.%s=%d is invalid, keeping default", field, v) + return + } + set(v) + } + apply("retryDelayMs", o.RetryDelayMs, func(v int) { cfg.RetryDelay = time.Duration(v) * time.Millisecond }) + apply("recheckThreshold", o.RecheckThreshold, func(v int) { cfg.RecheckThreshold = v }) + apply("tipRecheckThreshold", o.TipRecheckThreshold, func(v int) { cfg.TipRecheckThreshold = v }) + apply("maxStallMs", o.MaxStallMs, func(v int) { cfg.MaxStallDuration = time.Duration(v) * time.Millisecond }) + if cfg.TipRecheckThreshold > cfg.RecheckThreshold { + glog.Warningf("sync: missingBlockRetry.tipRecheckThreshold=%d exceeds recheckThreshold=%d, clamping to %d", + cfg.TipRecheckThreshold, cfg.RecheckThreshold, cfg.RecheckThreshold) + cfg.TipRecheckThreshold = cfg.RecheckThreshold + } + return cfg +} + // 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 + } + // MaxStallDuration is the load-bearing liveness cap (see its doc): the retry + // loops disable the cap when it's <= 0, which would let a chain-shortening + // reorg spin forever. Enforce the invariant structurally here so every caller + // (including tests passing a partial cfg) gets a safe value, not just the + // ApplyMissingBlockRetryOverride path. + if effectiveCfg.MissingBlockRetry.MaxStallDuration <= 0 { + effectiveCfg.MissingBlockRetry.MaxStallDuration = DefaultMissingBlockRetryConfig().MaxStallDuration + } 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") +// syncNotNeeded is returned by resyncIndex when the local tip already matches +// the backend tip. ResyncIndex treats it as a successful no-op. +var syncNotNeeded = errors.New("sync not needed") 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") @@ -58,20 +162,26 @@ func (w *SyncWorker) updateBackendInfo() { ci = &bchain.ChainInfo{} } w.is.SetBackendInfo(&common.BackendInfo{ - BackendError: backendError, - BestBlockHash: ci.Bestblockhash, - Blocks: ci.Blocks, - Chain: ci.Chain, - Difficulty: ci.Difficulty, - Headers: ci.Headers, - ProtocolVersion: ci.ProtocolVersion, - SizeOnDisk: ci.SizeOnDisk, - Subversion: ci.Subversion, - Timeoffset: ci.Timeoffset, - Version: ci.Version, - Warnings: ci.Warnings, - Consensus: ci.Consensus, + BackendError: backendError, + BestBlockHash: ci.Bestblockhash, + Blocks: ci.Blocks, + Chain: ci.Chain, + Difficulty: ci.Difficulty, + Headers: ci.Headers, + ProtocolVersion: ci.ProtocolVersion, + SizeOnDisk: ci.SizeOnDisk, + Subversion: ci.Subversion, + Timeoffset: ci.Timeoffset, + Version: ci.Version, + Warnings: ci.Warnings, + ConsensusVersion: ci.ConsensusVersion, + Consensus: ci.Consensus, }) + // Refresh tip-age on every resync outcome (nil/syncNotNeeded/error), not only on a + // successful connect: during a silent stall resyncIndex returns syncNotNeeded each + // run, so without this the gauge would only be refreshed by the ~15-minute app-info + // loop. A climbing blockbook_tip_age_seconds is the primary stall signal. + w.metrics.BackendTipAgeSeconds.Set(time.Since(w.is.GetBackendTipLastAdvance()).Seconds()) } // ResyncIndex synchronizes index to the top of the blockchain @@ -88,23 +198,25 @@ func (w *SyncWorker) ResyncIndex(onNewBlock bchain.OnNewBlockFunc, initialSync b switch err { case nil: d := time.Since(start) - glog.Info("resync: ", d) w.metrics.IndexResyncDuration.Observe(float64(d) / 1e6) // in milliseconds w.metrics.IndexDBSize.Set(float64(w.db.DatabaseSizeOnDisk())) - bh, _, err := w.db.GetBestBlock() + bh, bhash, err := w.db.GetBestBlock() if err == nil { w.is.FinishedSync(bh) } + // Single steady-state sync line: what we synced to and how long it took. + // The "is behind" trigger and the connectBlocks "synced at" line are + // intentionally not logged, as they fire every block on fast chains. + glog.Infof("resync: synced at %d %s in %s", bh, bhash, d) w.metrics.BackendBestHeight.Set(float64(w.is.BackendInfo.Blocks)) w.metrics.BlockbookBestHeight.Set(float64(bh)) return err - case errSynced: - // this is not actually error but flag that resync wasn't necessary + case syncNotNeeded: w.is.FinishedSyncNoChange() w.metrics.IndexDBSize.Set(float64(w.db.DatabaseSizeOnDisk())) if initialSync { d := time.Since(start) - glog.Info("resync: ", d) + glog.Info("resync: finished in ", d) } return nil } @@ -126,12 +238,12 @@ func (w *SyncWorker) resyncIndex(onNewBlock bchain.OnNewBlockFunc, initialSync b // If the locally indexed block is the same as the best block on the network, we're done. if localBestHash == remoteBestHash { glog.Infof("resync: synced at %d %s", localBestHeight, localBestHash) - return errSynced + return syncNotNeeded } 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 { @@ -139,7 +251,6 @@ func (w *SyncWorker) resyncIndex(onNewBlock bchain.OnNewBlockFunc, initialSync b glog.Info("resync: local is forked at height ", localBestHeight, ", local hash ", localBestHash, ", remote hash ", remoteHash) return w.handleFork(localBestHeight, localBestHash, onNewBlock, initialSync) } - glog.Info("resync: local at ", localBestHeight, " is behind") w.startHeight = localBestHeight + 1 } else { // database is empty, start genesis @@ -152,28 +263,56 @@ 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 } if remoteBestHeight < w.startHeight { - 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 + glog.Warning("resync: observed remote best height ", remoteBestHeight, " less than sync start height ", w.startHeight, ", falling back to sequential sync") + } else { + 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 @@ -183,7 +322,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 @@ -192,8 +331,16 @@ func (w *SyncWorker) handleFork(localBestHeight uint32, localBestHash string, on break } 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 { + // A tolerated ErrBlockNotFound leaves remote == "", which (local is non-empty + // here) counts this height as forked and disconnects it. That is intended: for + // EVM the backend can sit at a lower height after a rollback, and those blocks + // must be disconnected to realign with the chain. The tradeoff is that on a + // load-balanced backend a transient lagging node can answer NotFound for a block + // that is still canonical, over-disconnecting — bounded and self-healing, since + // the resyncIndex below re-connects them. Treating NotFound as a stop instead + // would be worse: genuinely orphaned blocks would stay connected after a real + // rollback, leaving the index wedged ahead of the backend. + if err != nil && !stdErrors.Is(err, bchain.ErrBlockNotFound) { return err } if local == remote { @@ -201,6 +348,7 @@ func (w *SyncWorker) handleFork(localBestHeight uint32, localBestHash string, on } hashes = append(hashes, local) } + w.metrics.IndexReorgEvents.With(common.Labels{"type": "disconnect"}).Inc() if err := w.DisconnectBlocks(height+1, localBestHeight, hashes); err != nil { return err } @@ -214,7 +362,7 @@ func (w *SyncWorker) connectBlocks(onNewBlock bchain.OnNewBlockFunc, initialSync go w.getBlockChain(bch, done) - var lastRes, empty blockResult + var lastRes blockResult connect := func(res blockResult) error { lastRes = res @@ -226,7 +374,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 { @@ -236,46 +384,393 @@ func (w *SyncWorker) connectBlocks(onNewBlock bchain.OnNewBlockFunc, initialSync return nil } - if initialSync { - ConnectLoop: + logInterrupted := func() { + if lastRes.block != nil { + glog.Info("connectBlocks interrupted at height ", lastRes.block.Height) + } else { + glog.Info("connectBlocks interrupted") + } + } + // 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, ok := <-bch: + if !ok { + select { + case <-w.chanOsSignal: + logInterrupted() + return ErrOperationInterrupted + default: + } + break ConnectLoop + } + err := connect(res) + if err != nil { + return err + } + } + } + + return nil +} + +type hashHeight struct { + hash string + height uint32 +} + +// sendHashHeight queues hh but stays abort-aware: if a full hch made this a blocking +// send, the coordinator could never read abortCh and sync would wedge. On abort hh is +// intentionally dropped since the round is being torn down anyway. +func (w *SyncWorker) sendHashHeight(hch chan<- hashHeight, abortCh <-chan error, hh hashHeight) error { + select { + case hch <- hh: + return nil + case abortErr := <-abortCh: + return abortErr + case <-w.chanOsSignal: + return ErrOperationInterrupted + } +} + +func (w *SyncWorker) shouldRestartSyncOnMissingBlock(height uint32, expectedHash string) (bool, error) { + // When a block hash disappears at a given height, it can indicate a + // reorg/rollback, but on load-balanced EVM RPCs a single lagging backend can + // also report an older tip. Only restart immediately when another probe can + // prove the height exists with a different hash; otherwise let the retry + // loop or wall-clock cap yield control to the outer resync. + bestHeight, err := w.chain.GetBestBlockHeight() + if err != nil { + return false, err + } + if bestHeight < height { + return false, nil + } + currentHash, err := w.chain.GetBlockHash(height) + if err != nil { + if stdErrors.Is(err, bchain.ErrBlockNotFound) { + return false, nil + } + return false, err + } + return currentHash != expectedHash, nil +} + +// onRetryableMiss bumps the retry count and, once at threshold, rechecks chain +// state. It emits a single warning at the crossover; below threshold it stays +// quiet because transient backend lag (e.g. load-balanced RPC routing skew) +// is expected. The per-error signal is preserved via the IndexResyncErrors metric. +func (w *SyncWorker) onRetryableMiss(retries *int, threshold int, label string, height uint32, hash string, err error) (bool, error) { + (*retries)++ + if *retries < threshold { + return false, nil + } + if *retries == threshold { + glog.Warningf("%s: block %d %s still missing after %d retries (last: %v); rechecking chain state", + label, height, hash, *retries, err) + } + return w.shouldRestartSyncOnMissingBlock(height, hash) +} + +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, syncWorkers) + for i := 0; i < int(syncWorkers); i++ { + bch[i] = make(chan *bchain.Block) + } + 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) + lastBlock := lower - 1 + WriteBlockLoop: for { select { - case <-w.chanOsSignal: - glog.Info("connectBlocks interrupted at height ", lastRes.block.Height) - return ErrOperationInterrupted - case res := <-bch: - if res == empty { - break ConnectLoop + case b := <-bch[(lastBlock+1)%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 := connect(res) + err := w.db.ConnectBlock(b) if err != nil { - return err + 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 } } - } else { - // while regular sync, OS sig is handled by waitForSignalAndShutdown - for res := range bch { - err := connect(res) + if err != nil { + glog.Error("sync: ParallelConnectBlocks.Close error ", err) + } + glog.Info("WriteBlock exiting...") + } + 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 { - return err + glog.Error("GetBlockHash error ", err) + w.metrics.IndexResyncErrors.With(common.Labels{"error": "failure"}).Inc() + time.Sleep(time.Millisecond * 500) + continue + } + if err = w.sendHashHeight(hch, abortCh, hashHeight{hash, h}); err != nil { + if stdErrors.Is(err, errResync) { + glog.Warning("sync: parallel connect aborted while queueing block hash, restarting sync") + } else if stdErrors.Is(err, ErrOperationInterrupted) { + glog.Info("connectBlocksParallel interrupted at height ", h) + } else { + glog.Error("sync: parallel connect aborted while queueing block hash, worker error ", err) + } + close(terminating) + break ConnectLoop } + h++ } } - - if lastRes.block != nil { - glog.Infof("resync: synced at %d %s", lastRes.block.Height, lastRes.block.Hash) + 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: } - - return nil + for i := 0; i < int(syncWorkers); i++ { + close(bch[i]) + } + <-writeBlockDone + return err } -// 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 +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 + label := "getBlockWorker " + strconv.Itoa(i) + const checkErrStreakLimit = 3 +GetBlockLoop: + for hh := range hch { + // Track consecutive retryable errors per block so we only re-check the + // chain once the backend has had a chance to catch up. + retries := 0 + checkErrStreak := 0 + loopStart := time.Now() + 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 stdErrors.Is(err, bchain.ErrBlockNotFound) { + w.metrics.IndexBlockNotFoundRetries.Inc() + } + if isRetryableGetBlockError(err) { + 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 + } + restart, checkErr := w.onRetryableMiss(&retries, threshold, label, hh.height, hh.hash, err) + if checkErr != nil { + checkErrStreak++ + if checkErrStreak == 1 { + glog.Warningf("%s: chain-state probe failed for block %d %s (last: %v); will abort after %d consecutive failures", + label, hh.height, hh.hash, checkErr, checkErrStreakLimit) + } + if checkErrStreak >= checkErrStreakLimit { + // Backend cannot answer chain-state probes either; surface so the + // outer loop can decide how to recover instead of spinning silently. + glog.Errorf("%s: aborting after %d consecutive chain-state probe failures (last: %v)", + label, checkErrStreak, checkErr) + w.metrics.IndexSyncYields.With(common.Labels{"reason": "probe_failed"}).Inc() + select { + case abortCh <- checkErr: + default: + } + return + } + } else { + checkErrStreak = 0 + 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") + w.metrics.IndexReorgEvents.With(common.Labels{"type": "resync"}).Inc() + select { + case abortCh <- errResync: + default: + } + return + } + } + if cfg.MaxStallDuration > 0 && time.Since(loopStart) >= cfg.MaxStallDuration { + glog.Warningf("%s: block %d %s stall deadline %s exceeded after %d retries (last: %v); yielding to resync", + label, hh.height, hh.hash, cfg.MaxStallDuration, retries, err) + w.metrics.IndexSyncYields.With(common.Labels{"reason": "deadline"}).Inc() + 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 + } + retries = 0 + checkErrStreak = 0 + loopStart = time.Now() + glog.Error("getBlockWorker ", i, " connect block error ", err, ". Retrying...") + } + 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 + } + 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) @@ -287,6 +782,10 @@ func (w *SyncWorker) ConnectBlocksParallel(lower, higher uint32) error { 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() @@ -321,41 +820,9 @@ func (w *SyncWorker) ConnectBlocksParallel(lower, higher uint32) error { } 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 - if hchClosed.Load() == true { - glog.Error("getBlockWorker ", i, " connect block error ", err, ". Exiting...") - return - } - 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 - } - } - if w.dryRun { - continue - } - select { - case bch[hh.height%uint32(w.syncWorkers)] <- block: - case <-terminating: - break GetBlockLoop - } - } - glog.Info("getBlockWorker ", i, " 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 @@ -364,8 +831,18 @@ 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) + glog.Info("BulkConnectBlocks interrupted at height ", h) err = ErrOperationInterrupted // signal all workers to terminate their loops (error loops are interrupted below) close(terminating) @@ -378,14 +855,26 @@ ConnectLoop: time.Sleep(time.Millisecond * 500) continue } - hch <- hashHeight{hash, h} + if err = w.sendHashHeight(hch, abortCh, hashHeight{hash, h}); err != nil { + if stdErrors.Is(err, errResync) { + glog.Warning("sync: bulk connect aborted while queueing block hash, restarting sync") + } else if stdErrors.Is(err, ErrOperationInterrupted) { + glog.Info("BulkConnectBlocks interrupted at height ", h) + } else { + glog.Error("sync: bulk connect aborted while queueing block hash, worker error ", err) + } + close(terminating) + break ConnectLoop + } if h > 0 && h%1000 == 0 { w.metrics.BlockbookBestHeight.Set(float64(h)) glog.Info("connecting block ", h, " ", hash, ", elapsed ", time.Since(start), " ", w.db.GetAndResetConnectBlockStats()) start = time.Now() } if msTime.Before(time.Now()) { - glog.Info(w.db.GetMemoryStats()) + if glog.V(1) { + glog.Info(w.db.GetMemoryStats()) + } w.metrics.IndexDBSize.Set(float64(w.db.DatabaseSizeOnDisk())) msTime = time.Now().Add(10 * time.Minute) } @@ -397,6 +886,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]) } @@ -415,24 +913,91 @@ func (w *SyncWorker) getBlockChain(out chan blockResult, done chan struct{}) { hash := w.startHash height := w.startHeight prevHash := "" - + cfg := w.missingBlockRetry + retryDelay := cfg.RetryDelay + if retryDelay <= 0 || retryDelay > 250*time.Millisecond { + retryDelay = 250 * time.Millisecond + } + recheckThreshold := cfg.TipRecheckThreshold + if recheckThreshold <= 0 { + recheckThreshold = 1 + } + maxStall := cfg.MaxStallDuration // loop until error ErrBlockNotFound for { select { case <-done: return + case <-w.chanOsSignal: + return default: } - block, err := w.chain.GetBlock(hash, height) - if err != nil { - if err == bchain.ErrBlockNotFound { + retries := 0 + loopStart := time.Now() + var block *bchain.Block + var err error + for { + block, err = w.chain.GetBlock(hash, height) + if err == nil { break } - out <- blockResult{err: err} - return + // On the first ErrBlockNotFound, check whether we are past the backend tip + // so we exit cleanly at end-of-chain. Subsequent retries skip this RPC and + // defer to shouldRestartSyncOnMissingBlock at the threshold tick. + gotNotFound := stdErrors.Is(err, bchain.ErrBlockNotFound) + if retries == 0 && gotNotFound { + bestHeight, bestErr := w.chain.GetBestBlockHeight() + if bestErr != nil { + out <- blockResult{err: bestErr} + return + } + if height > bestHeight { + if hash == "" { + return + } + glog.Warningf("getBlockChain: block %d %s is above observed backend height %d; retrying because the block hash was already observed", height, hash, bestHeight) + } + } + if gotNotFound { + w.metrics.IndexBlockNotFoundRetries.Inc() + } + if !isRetryableGetBlockError(err) { + out <- blockResult{err: err} + return + } + resync, checkErr := w.onRetryableMiss(&retries, recheckThreshold, "getBlockChain", height, hash, err) + if checkErr != nil { + out <- blockResult{err: checkErr} + return + } + if resync { + w.metrics.IndexReorgEvents.With(common.Labels{"type": "resync"}).Inc() + out <- blockResult{err: errResync} + return + } + if maxStall > 0 && time.Since(loopStart) >= maxStall { + glog.Warningf("getBlockChain: block %d %s stall deadline %s exceeded after %d retries (last: %v); yielding to resync", + height, hash, maxStall, retries, err) + w.metrics.IndexSyncYields.With(common.Labels{"reason": "deadline"}).Inc() + select { + case out <- blockResult{err: errResync}: + case <-done: + case <-w.chanOsSignal: + } + return + } + w.metrics.IndexResyncErrors.With(common.Labels{"error": "failure"}).Inc() + select { + case <-done: + return + case <-w.chanOsSignal: + return + case <-time.After(retryDelay): + } } if block.Prev != "" && prevHash != "" && prevHash != block.Prev { glog.Infof("sync: fork detected at height %d %s, local prevHash %s, remote prevHash %s", height, block.Hash, prevHash, block.Prev) + w.metrics.IndexReorgEvents.With(common.Labels{"type": "fork"}).Inc() out <- blockResult{err: errFork} return } diff --git a/db/sync_test.go b/db/sync_test.go new file mode 100644 index 0000000000..c774dcfa23 --- /dev/null +++ b/db/sync_test.go @@ -0,0 +1,562 @@ +//go:build unittest + +package db + +import ( + "context" + stdErrors "errors" + "io" + "net" + "net/url" + "os" + "strconv" + "sync" + "sync/atomic" + "syscall" + "testing" + "time" + + jujuErrors "github.com/juju/errors" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/common" +) + +var ( + testMetricsOnce sync.Once + testMetrics *common.Metrics + testMetricsErr error +) + +func getTestMetrics(t *testing.T) *common.Metrics { + testMetricsOnce.Do(func() { + testMetrics, testMetricsErr = common.GetMetrics("test") + }) + if testMetricsErr != nil { + t.Fatalf("GetMetrics: %v", testMetricsErr) + } + return testMetrics +} + +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) + } + }) + } +} + +func TestConnectBlocksHonorsClosedShutdownBeforeStart(t *testing.T) { + for i := 0; i < 100; i++ { + ch := make(chan os.Signal) + close(ch) + + w := &SyncWorker{ + chanOsSignal: ch, + } + + if err := w.connectBlocks(nil, false); !stdErrors.Is(err, ErrOperationInterrupted) { + t.Fatalf("connectBlocks error = %v, want %v", err, ErrOperationInterrupted) + } + } +} + +type getBlockChainTestChain struct { + bchain.BlockChain + bestHeight uint32 + bestHeightErr error + bestHeightCalls int + hashes map[uint32]string + blocks map[uint32]*bchain.Block + blockErrors map[uint32][]error + getBlockCalls map[uint32]int + getBlockHashErr error +} + +func (c *getBlockChainTestChain) GetBestBlockHeight() (uint32, error) { + c.bestHeightCalls++ + if c.bestHeightErr != nil { + return 0, c.bestHeightErr + } + return c.bestHeight, nil +} + +func (c *getBlockChainTestChain) GetBlockHash(height uint32) (string, error) { + if c.getBlockHashErr != nil { + return "", c.getBlockHashErr + } + if hash, ok := c.hashes[height]; ok { + return hash, nil + } + return "", bchain.ErrBlockNotFound +} + +func (c *getBlockChainTestChain) GetBlock(hash string, height uint32) (*bchain.Block, error) { + c.getBlockCalls[height]++ + if errs := c.blockErrors[height]; len(errs) > 0 { + err := errs[0] + c.blockErrors[height] = errs[1:] + return nil, err + } + if block := c.blocks[height]; block != nil { + copy := *block + return ©, nil + } + return nil, bchain.ErrBlockNotFound +} + +func newGetBlockChainTestWorker(t *testing.T, chain *getBlockChainTestChain, startHash string, startHeight uint32) *SyncWorker { + return &SyncWorker{ + chain: chain, + startHash: startHash, + startHeight: startHeight, + missingBlockRetry: MissingBlockRetryConfig{ + TipRecheckThreshold: 2, + RetryDelay: time.Millisecond, + }, + metrics: getTestMetrics(t), + } +} + +func runGetBlockChain(w *SyncWorker) []blockResult { + out := make(chan blockResult) + done := make(chan struct{}) + go w.getBlockChain(out, done) + var results []blockResult + for res := range out { + results = append(results, res) + } + return results +} + +func TestGetBlockChainRetriesSequentialTipBlock(t *testing.T) { + chain := &getBlockChainTestChain{ + bestHeight: 1, + hashes: map[uint32]string{1: "h1"}, + blocks: map[uint32]*bchain.Block{ + 1: {BlockHeader: bchain.BlockHeader{Hash: "h1", Height: 1}}, + }, + blockErrors: map[uint32][]error{ + 1: {bchain.ErrBlockNotFound, bchain.ErrBlockNotFound}, + }, + getBlockCalls: map[uint32]int{}, + } + w := newGetBlockChainTestWorker(t, chain, "h1", 1) + + results := runGetBlockChain(w) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if results[0].err != nil { + t.Fatalf("unexpected error: %v", results[0].err) + } + if results[0].block == nil || results[0].block.Hash != "h1" { + t.Fatalf("unexpected block: %+v", results[0].block) + } + if calls := chain.getBlockCalls[1]; calls != 3 { + t.Fatalf("GetBlock height 1 calls = %d, want 3", calls) + } +} + +func TestGetBlockChainStopsAboveBestHeight(t *testing.T) { + chain := &getBlockChainTestChain{ + bestHeight: 0, + hashes: map[uint32]string{}, + blocks: map[uint32]*bchain.Block{}, + blockErrors: map[uint32][]error{}, + getBlockCalls: map[uint32]int{}, + } + w := newGetBlockChainTestWorker(t, chain, "", 1) + + results := runGetBlockChain(w) + if len(results) != 0 { + t.Fatalf("got %d results, want 0: %+v", len(results), results) + } + if calls := chain.getBlockCalls[1]; calls != 1 { + t.Fatalf("GetBlock height 1 calls = %d, want 1", calls) + } +} + +func TestGetBlockChainRetriesKnownHashAboveObservedBestHeight(t *testing.T) { + chain := &getBlockChainTestChain{ + bestHeight: 0, + hashes: map[uint32]string{1: "h1"}, + blocks: map[uint32]*bchain.Block{ + 1: {BlockHeader: bchain.BlockHeader{Hash: "h1", Height: 1}}, + }, + blockErrors: map[uint32][]error{ + 1: {bchain.ErrBlockNotFound}, + }, + getBlockCalls: map[uint32]int{}, + } + w := newGetBlockChainTestWorker(t, chain, "h1", 1) + + results := runGetBlockChain(w) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if results[0].err != nil { + t.Fatalf("unexpected error: %v", results[0].err) + } + if results[0].block == nil || results[0].block.Hash != "h1" { + t.Fatalf("unexpected block: %+v", results[0].block) + } + if calls := chain.getBlockCalls[1]; calls != 2 { + t.Fatalf("GetBlock height 1 calls = %d, want 2", calls) + } +} + +func TestGetBlockChainMissingBlockChangedHashResyncs(t *testing.T) { + chain := &getBlockChainTestChain{ + bestHeight: 1, + hashes: map[uint32]string{1: "real-hash"}, + blocks: map[uint32]*bchain.Block{}, + blockErrors: map[uint32][]error{}, + getBlockCalls: map[uint32]int{}, + } + w := newGetBlockChainTestWorker(t, chain, "fake-hash", 1) + + results := runGetBlockChain(w) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if !stdErrors.Is(results[0].err, errResync) { + t.Fatalf("error = %v, want errResync", results[0].err) + } + if calls := chain.getBlockCalls[1]; calls != 2 { + t.Fatalf("GetBlock height 1 calls = %d, want 2", calls) + } +} + +func TestShouldRestartSyncOnMissingBlockIgnoresLaggingBestHeight(t *testing.T) { + chain := &getBlockChainTestChain{ + bestHeight: 9, + hashes: map[uint32]string{}, + } + w := newGetBlockChainTestWorker(t, chain, "h10", 10) + + restart, err := w.shouldRestartSyncOnMissingBlock(10, "h10") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if restart { + t.Fatal("restart = true, want false for a single lagging best-height probe") + } +} + +func TestShouldRestartSyncOnMissingBlockIgnoresMissingHashProbe(t *testing.T) { + chain := &getBlockChainTestChain{ + bestHeight: 10, + hashes: map[uint32]string{}, + } + w := newGetBlockChainTestWorker(t, chain, "h10", 10) + + restart, err := w.shouldRestartSyncOnMissingBlock(10, "h10") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if restart { + t.Fatal("restart = true, want false for a single missing hash probe") + } +} + +func TestGetBlockChainNonRetryableErrorReturns(t *testing.T) { + boom := stdErrors.New("boom") + chain := &getBlockChainTestChain{ + bestHeight: 1, + hashes: map[uint32]string{1: "h1"}, + blocks: map[uint32]*bchain.Block{}, + blockErrors: map[uint32][]error{ + 1: {boom}, + }, + getBlockCalls: map[uint32]int{}, + } + w := newGetBlockChainTestWorker(t, chain, "h1", 1) + + results := runGetBlockChain(w) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if !stdErrors.Is(results[0].err, boom) { + t.Fatalf("error = %v, want %v", results[0].err, boom) + } + if calls := chain.getBlockCalls[1]; calls != 1 { + t.Fatalf("GetBlock height 1 calls = %d, want 1", calls) + } +} + +func TestGetBlockChainWallClockCap(t *testing.T) { + // Block 1 exists on chain (so first ErrBlockNotFound does not short-circuit + // to "above best height") but GetBlock never produces it. TipRecheckThreshold + // is set high enough that the recheck path cannot fire before the cap. + chain := &getBlockChainTestChain{ + bestHeight: 1, + hashes: map[uint32]string{1: "h1"}, + blocks: map[uint32]*bchain.Block{}, + blockErrors: map[uint32][]error{}, + getBlockCalls: map[uint32]int{}, + } + w := &SyncWorker{ + chain: chain, + startHash: "h1", + startHeight: 1, + missingBlockRetry: MissingBlockRetryConfig{ + TipRecheckThreshold: 1_000_000, + RetryDelay: time.Millisecond, + MaxStallDuration: 50 * time.Millisecond, + }, + metrics: getTestMetrics(t), + } + + start := time.Now() + results := runGetBlockChain(w) + elapsed := time.Since(start) + + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if !stdErrors.Is(results[0].err, errResync) { + t.Fatalf("error = %v, want errResync", results[0].err) + } + if elapsed < 50*time.Millisecond { + t.Fatalf("wall-clock cap returned in %v, expected at least 50ms", elapsed) + } + if elapsed > 2*time.Second { + t.Fatalf("wall-clock cap took %v, expected to return shortly after 50ms", elapsed) + } + if calls := chain.getBlockCalls[1]; calls < 2 { + t.Fatalf("GetBlock height 1 calls = %d, want at least 2", calls) + } +} + +func TestGetBlockWorkerCheckErrAbortsAfterStreak(t *testing.T) { + // GetBlock keeps returning ErrBlockNotFound (retryable). GetBestBlockHeight + // fails too, so onRetryableMiss returns (false, checkErr) on every call past + // the threshold. After three consecutive checkErrs the worker must surface + // the error via abortCh instead of spinning silently. + probeErr := stdErrors.New("backend unreachable") + chain := &getBlockChainTestChain{ + bestHeight: 1, + bestHeightErr: probeErr, + hashes: map[uint32]string{1: "h1"}, + blocks: map[uint32]*bchain.Block{}, + blockErrors: map[uint32][]error{}, + getBlockCalls: map[uint32]int{}, + } + w := &SyncWorker{ + chain: chain, + missingBlockRetry: MissingBlockRetryConfig{ + RecheckThreshold: 1, + TipRecheckThreshold: 1, + RetryDelay: time.Millisecond, + MaxStallDuration: 10 * time.Second, // do not let the wall-clock cap fire first + }, + metrics: getTestMetrics(t), + } + + const workers = 1 + hch := make(chan hashHeight, workers) + bch := make([]chan *bchain.Block, workers) + for i := range bch { + bch[i] = make(chan *bchain.Block, 1) + } + var hchClosed atomic.Value + hchClosed.Store(true) + terminating := make(chan struct{}) + abortCh := make(chan error, 1) + hch <- hashHeight{hash: "h1", height: 1} + close(hch) + + var wg sync.WaitGroup + wg.Add(1) + go w.getBlockWorker(0, workers, &wg, hch, bch, &hchClosed, terminating, abortCh) + + select { + case err := <-abortCh: + if !stdErrors.Is(err, probeErr) { + t.Fatalf("abortCh got %v, want %v", err, probeErr) + } + case <-time.After(2 * time.Second): + close(terminating) + t.Fatalf("worker did not abort after consecutive checkErrs") + } + + wg.Wait() + if chain.bestHeightCalls < 3 { + t.Fatalf("GetBestBlockHeight calls = %d, want at least 3", chain.bestHeightCalls) + } +} + +func TestParallelConnectBlocksReturnsWorkerAbortWhenHashQueueFull(t *testing.T) { + hashes := make(map[uint32]string) + for h := uint32(1); h <= 10; h++ { + hashes[h] = "h" + strconv.Itoa(int(h)) + } + chain := &getBlockChainTestChain{ + bestHeight: 10, + hashes: hashes, + blocks: map[uint32]*bchain.Block{}, + blockErrors: map[uint32][]error{}, + getBlockCalls: map[uint32]int{}, + } + w := &SyncWorker{ + chain: chain, + missingBlockRetry: MissingBlockRetryConfig{ + RecheckThreshold: 1, + TipRecheckThreshold: 1, + RetryDelay: time.Millisecond, + MaxStallDuration: 30 * time.Millisecond, + }, + metrics: getTestMetrics(t), + } + + done := make(chan error, 1) + go func() { + done <- w.ParallelConnectBlocks(nil, 1, 10, 1) + }() + + select { + case err := <-done: + if !stdErrors.Is(err, errResync) { + t.Fatalf("ParallelConnectBlocks error = %v, want errResync", err) + } + case <-time.After(2 * time.Second): + t.Fatal("ParallelConnectBlocks did not return after worker abort") + } +} + +// MaxStallDuration is the load-bearing liveness cap: the retry loops disable the +// cap when it is <= 0, so construction must clamp it to a safe default regardless +// of which caller (or partial test cfg) supplied the config. +func TestNewSyncWorkerClampsMaxStallDuration(t *testing.T) { + def := DefaultMissingBlockRetryConfig().MaxStallDuration + cases := []struct { + name string + cfg *SyncWorkerConfig + want time.Duration + }{ + {name: "nil cfg keeps default", cfg: nil, want: def}, + { + name: "zero stall clamped to default", + cfg: &SyncWorkerConfig{MissingBlockRetry: MissingBlockRetryConfig{MaxStallDuration: 0}}, + want: def, + }, + { + name: "negative stall clamped to default", + cfg: &SyncWorkerConfig{MissingBlockRetry: MissingBlockRetryConfig{MaxStallDuration: -time.Second}}, + want: def, + }, + { + name: "explicit positive stall preserved", + cfg: &SyncWorkerConfig{MissingBlockRetry: MissingBlockRetryConfig{MaxStallDuration: 5 * time.Second}}, + want: 5 * time.Second, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w, err := NewSyncWorkerWithConfig(nil, nil, 1, 0, 0, false, nil, getTestMetrics(t), nil, tc.cfg) + if err != nil { + t.Fatalf("NewSyncWorkerWithConfig: %v", err) + } + if got := w.missingBlockRetry.MaxStallDuration; got != tc.want { + t.Fatalf("MaxStallDuration = %s, want %s", got, tc.want) + } + }) + } +} diff --git a/db/test_helper.go b/db/test_helper.go index f3499ca5c3..2779a58398 100644 --- a/db/test_helper.go +++ b/db/test_helper.go @@ -3,7 +3,7 @@ package db import ( - "github.com/syscoin/blockbook/bchain" + "github.com/trezor/blockbook/bchain" ) func SetBlockChain(w *SyncWorker, chain bchain.BlockChain) { @@ -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 75c79ce15b..bcc89bc254 100644 --- a/db/txcache.go +++ b/db/txcache.go @@ -3,9 +3,9 @@ package db import ( "github.com/golang/glog" "github.com/juju/errors" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/eth" - "github.com/syscoin/blockbook/common" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/common" ) // TxCache is handle to TxCacheServer @@ -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..ac437e984d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,8 +2,13 @@ * [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 +* [API (Syscoin specifics)](/docs/api-syscoin.md) – Syscoin SPT, send transaction, and validation extensions for API V2 +* [Sync](/docs/sync.md) – Sync-loop architecture and the `missingBlockRetry` troubleshooting knobs * [Testing](/docs/testing.md) – Description of tests used during Blockbook development diff --git a/docs/api-syscoin.md b/docs/api-syscoin.md new file mode 100644 index 0000000000..1871507051 --- /dev/null +++ b/docs/api-syscoin.md @@ -0,0 +1,98 @@ +# Syscoin API Notes + +Syscoin uses the standard Blockbook API V2 shape for base coin transactions and +extends it only where Syscoin Platform Token (SPT), NEVM bridge, or Syscoin Core +RPC behavior differs from ordinary Bitcoin-like chains. + +## Transactions and UTXOs + +SPT-aware inputs, outputs, and UTXOs include `assetInfo` when the indexed value +belongs to an asset allocation: + +```json +{ + "assetInfo": { + "assetGuid": "123456", + "value": "100000000", + "valueStr": "1.00000000", + "symbol": "SYSX" + } +} +``` + +`assetGuid` is the decimal Syscoin asset GUID. `value` is the raw integer asset +amount in the asset base unit. `valueStr` is formatted using the chain parser +decimal rules. `symbol` is included when asset metadata is available. + +Transaction responses may also include: + +- `tokenType`: the Syscoin SPT transaction class derived from the transaction + version, such as `SPT`, `SPTAssetAllocationSend`, + `SPTAssetAllocationMint`, `SPTSyscoinBurnToAllocation`, + `SPTAssetAllocationBurnToSyscoin`, or `SPTAssetAllocationBurnToNEVM`. +- `memo`: the raw Syscoin memo bytes when present. + +These fields appear on existing transaction endpoints, including +`/api/v2/tx/{txid}` and address transaction lists. They are omitted for ordinary +base coin transactions. + +## Address Responses + +Address responses retain the standard Blockbook `tokens` array for regular +token-like metadata and add Syscoin SPT fields where applicable: + +- `usedAssetTokens`: number of SPT assets historically used by the address. +- `tokensAsset`: SPT asset balances and metadata associated with the address. +- `assetGuid`: the SPT asset GUID on token entries where the token represents a + Syscoin asset. +- `unconfirmedBalanceSat`: unconfirmed SPT balance delta when mempool data is + available. + +The internal address filter supports a Syscoin asset transaction bitmask: + +- `0`: all Syscoin/base coin transaction classes. +- `1`: base coin only. +- `2`: asset allocation send. +- `4`: SYS burn to asset allocation. +- `8`: asset allocation burn to SYS. +- `16`: asset allocation burn to NEVM. +- `32`: asset allocation mint. + +## Sending Raw Transactions + +Syscoin accepts the standard raw transaction body and also JSON payloads with +Syscoin Core's optional burn and fee controls: + +```json +{ + "hex": "", + "maxfeerate": "0.10", + "maxburnamount": "150" +} +``` + +The same options are accepted as query/form fields on send endpoints and as +WebSocket fields on `sendTransaction`: + +```json +{ + "id": "1", + "method": "sendTransaction", + "params": { + "hex": "", + "maxfeerate": "0.10", + "maxburnamount": "150" + } +} +``` + +`maxburnamount` is required for transactions that intentionally burn SYS, such +as governance proposal collateral. If omitted, Syscoin Core's default burn +allowance is used. + +## Validation + +The Syscoin integration test matrix enables RPC, sync, and HTTP connectivity +checks for `syscoin` and `syscoin_testnet`. Live validation should be run +against Syscoin Core 5.1+ with `txindex=1`, ZMQ hashblock/hashtx enabled, and +the generated `blockchaincfg.json` for the matching network. diff --git a/docs/api-tron.md b/docs/api-tron.md new file mode 100644 index 0000000000..e206615eb7 --- /dev/null +++ b/docs/api-tron.md @@ -0,0 +1,103 @@ +# 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` +- `note` (`string`): decoded transaction memo from `raw_data.data` returned by Tron `wallet/gettransactionbyid` +- `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", + "note": "test", + "totalFee": "3076500", + "energyUsageTotal": "14650", + "bandwidthUsage": "345", + "result": "SUCCESS" + } +} +``` + +## Tron-specific account data (`Address.chainExtraData.payload`) + +On Tron, `Address.chainExtraData.payload` also includes staking/governance metadata in `stakingInfo` when available. `stakingInfo` is omitted for non-existent accounts or when required backend staking/account data is temporarily unavailable. If only supplemental reward data is unavailable, `stakingInfo` is still returned and `unclaimedReward` is reported as `0`. + +Resource fields: + +- `availableStakedBandwidth` (`number`): remaining bandwidth obtained by staking, computed as `max(NetLimit - NetUsed, 0)` +- `totalStakedBandwidth` (`number`): total bandwidth obtained by staking +- `availableFreeBandwidth` (`number`): remaining free bandwidth, computed as `max(freeNetLimit - freeNetUsed, 0)` +- `totalFreeBandwidth` (`number`): total daily free bandwidth +- `availableEnergy` (`number`): remaining energy, computed as `max(EnergyLimit - EnergyUsed, 0)` +- `totalEnergy` (`number`): total energy obtained by staking + +`stakingInfo` schema: + +- `stakedBalance` (`string`): total staked TRX in sun (Stake 2.0, bandwidth + energy) +- `stakedBalanceEnergy` (`string`): staked-for-energy amount in sun +- `stakedBalanceBandwidth` (`string`): staked-for-bandwidth amount in sun +- `unstakingBatches` (`array`): pending unstaking batches + - `amount` (`string`): unstaked amount in sun + - `expireTime` (`number`): Unix timestamp in **seconds** +- `totalVotingPower` (`string`): total TRON Power owned by the account +- `availableVotingPower` (`string`): remaining TRON Power available for voting, computed as `max(tronPowerLimit - tronPowerUsed, 0)` +- `votes` (`array`): current vote allocations + - `address` (`string`): SR address (base58) + - `voteCount` (`string`): vote count +- `unclaimedReward` (`string`): unclaimed voting reward (sun) +- `delegatedBalanceEnergy` (`string`): delegated staked energy in sun +- `delegatedBalanceBandwidth` (`string`): delegated staked bandwidth in sun diff --git a/docs/api.md b/docs/api.md index e6817ec12e..bf652fb192 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,1039 +1,35 @@ # Blockbook API -**Blockbook** provides REST, websocket and socket.io API to the indexed blockchain. +The canonical Blockbook API documentation is now the OpenAPI specification: -**Syscoin 5.0 Features**: This API now includes comprehensive support for Syscoin Platform Tokens (SPTs), NEVM (Network-Enhanced Virtual Machine) integration for ERC20/ERC721/ERC1155 token support, and cross-chain asset management between UTXO and EVM layers. +- [openapi.yaml](../openapi.yaml) -There are two versions of provided API. +Every Blockbook public server also serves the same specification and a local +Swagger UI: -## Legacy API V1 +- `/api-docs/` - read-only Swagger UI +- `/api-docs/openapi.yaml` - OpenAPI specification used by Swagger UI +- `/openapi.yaml` - direct machine-readable OpenAPI specification -The legacy API is a compatible subset of API provided by **Bitcore Insight**. It supports only Bitcoin-type coins. The details of the REST/socket.io requests can be found in the Insight's documentation. +For a local Blockbook public server, open the Swagger UI at the matching coin +port, for example: -### REST API -``` -GET /api/v1/block-index/ -GET /api/v1/tx/ -GET /api/v1/address/
-GET /api/v1/utxo/
-GET /api/v1/block/ -GET /api/v1/estimatefee/ -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 (Blockbook v0.3.5) also accessible without the */v1/* prefix, however in the future versions the version less access will be removed. - -## API V2 - -API V2 is the current version of API. It can be used with all coin types that Blockbook supports. API V2 can be accessed using REST and websocket interface. - -Common principles used in API V2: - -- all 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. - - -### 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) -- [Get asset](#get-asset) -- [Get assets](#get-assets) -- [Tickers list](#tickers-list) -- [Tickers](#tickers) -- [Balance history](#balance-history) - -#### Status page -Status page returns current status of Blockbook and connected backend. -``` -GET /api -``` - -Response: - -```javascript -{ - "blockbook": { - "coin": "Bitcoin", - "host": "blockbook", - "version": "0.3.6", - "gitCommit": "3d9ad91", - "buildTime": "2019-05-17T14:34:00+00:00", - "syncMode": true, - "initialSync": false, - "inSync": true, - "bestHeight": 577261, - "lastBlockTime": "2019-05-22T18:03:33.547762973+02:00", - "inSyncMempool": true, - "lastMempoolTime": "2019-05-22T18:10:10.27929383+02:00", - "mempoolSize": 17348, - "decimals": 8, - "dbSize": 191887866502, - "about": "Blockbook - blockchain indexer for Trezor wallet https://trezor.io/. 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": "" - } -} -``` - -#### Get block hash -``` -GET /api/v2/block-index/ -``` - -Response: - -```javascript -{ - "blockHash": "ed8f3af8c10ca70a136901c6dd3adf037f0aea8a93fbe9e80939214034300f1e" -} -``` - -_Note: Blockbook always follows the main chain of the backend it is attached to. See notes on **Get Block** below_ - -#### Get transaction -Get transaction returns "normalized" data about transaction, which has the same general structure for all supported coins. It does not return coin specific fields (for example information about Zcash shielded addresses). -``` -GET /api/v2/tx/ -``` - -Response for Bitcoin-type coins: - -```javascript -{ - "txid": "9e2bc8fbd40af17a6564831f84aef0cab2046d4bad19e91c09d21bff2c851851", - "version": 1, - "vin": [ - { - "txid": "f124e6999bf67e710b9e8a8ac4dbb08a64aa9c264120cf98793455e36a531615", - "vout": 2, - "sequence": 4294967295, - "n": 0, - "addresses": [ - "DDhUv8JZGmSxKYV95NLnbRTUKni9cDZD3S" - ], - "isAddress": true, - "value": "55795108999999", - "hex": "473...2c7ec77bb982" - } - ], - "vout": [ - { - "value": "55585679000000", - "n": 0, - "hex": "76a914feaca9d9fa7120c7c587c00c639bb18d40faadd388ac", - "addresses": [ - "DUMh1rPrXTrCN2Z9EHsLPg7b78rACHB2h7" - ], - "isAddress": true - }, - { - "value": "209329999999", - "n": 1, - "hex": "76a914ea8984be785868391d92f49c14933f47c152ea0a88ac", - "addresses": [ - "DSXDQ6rnwLX47WFRnemctoXPHA9pLMxqXn" - ], - "isAddress": true - } - ], - "blockHash": "78d1f3de899a10dd2e580704226ebf9508e95e1706f177fc9c31c47f245d2502", - "blockHeight": 2647927, - "confirmations": 1, - "blockTime": 1553088212, - "value": "55795008999999", - "valueIn": "55795108999999", - "fees": "100000000", - "hex": "0100000...0011000" -} -``` - -Response for Ethereum-type coins. There is always only one *vin*, only one *vout*, possibly an array of *tokenTransfers* and *ethereumSpecific* part. Note that *tokenTransfers* will also exist for any coins exposing a token interface including Ethereum and Syscoin. Missing is *hex* field: - -Note for Syscoin: SPT (Syscoin Platform Token) transactions will include `assetInfo` fields in `vin` and `vout` containing `assetGuid` and `value` for the SPT assets being transferred. The transaction may also include a `tokenType` field and `memo` field for SPT-specific data. - -```javascript -{ - "txid": "0xb78a36a4a0e7d708d595c3b193cace8f5b420e72e1f595a5387d87de509f0806", - "vin": [ - { - "n": 0, - "addresses": [ - "0x9c2e011c0ce0d75c2b62b9c5a0ba0a7456593803" - ], - "isAddress": true - } - ], - "vout": [ - { - "value": "0", - "n": 0, - "addresses": [ - "0xc32ae45504ee9482db99cfa21066a59e877bc0e6" - ], - "isAddress": true - } - ], - "blockHash": "0x39df7fb0893200e1e78c04f98691637a89b64e7a3edd96c16f2537e2fd56c414", - "blockHeight": 5241585, - "confirmations": 3, - "blockTime": 1553088337, - "value": "0", - "fees": "402501000000000", - "tokenTransfers": [ - { - "type": "ERC20", - "from": "0x9c2e011c0ce0d75c2b62b9c5a0ba0a7456593803", - "to": "0x583cbbb8a8443b38abcc0c956bece47340ea1367", - "token": "0xc32ae45504ee9482db99cfa21066a59e877bc0e6", - "name": "Tangany Test Token", - "symbol": "TATETO", - "decimals": 18, - "value": "133800000" - } - ], - "ethereumSpecific": { - "status": 1, - "nonce": 2830, - "gasLimit": 36591, - "gasUsed": 36591, - "gasPrice": "11000000000", - "data": "0xa9059cbb000000000000000000000000ba98d6a5" - } -} -``` - -Response for Syscoin SPT (Syscoin Platform Token) transactions: - -```javascript -{ - "txid": "9a42fc4b7f1c21fbc3b8b4e2c7f2e3e7c4d8e2a1b9c3e4f5a6b7c8d9e0f1a2b3", - "version": 142, - "vin": [ - { - "txid": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2", - "vout": 0, - "sequence": 4294967295, - "n": 0, - "addresses": [ - "sys1qk5x3j5s7n8t9k3c2x4v5b6n7m8l9p0a1s2d3f4g5h" - ], - "isAddress": true, - "value": "10000000000", - "assetInfo": { - "assetGuid": "123456789", - "value": "100000000000" - } - } - ], - "vout": [ - { - "value": "9999900000", - "n": 0, - "addresses": [ - "sys1qr6w5e4t3y2u1i9o8p7l6k5j4h3g2f1s0a9z8x7c6v" - ], - "isAddress": true, - "assetInfo": { - "assetGuid": "123456789", - "value": "50000000000" - } - }, - { - "value": "0", - "n": 1, - "addresses": [ - "sys1qm3n4b5v6c7x8z9a0s1d2f3g4h5j6k7l8p9o0i1u2y" - ], - "isAddress": true, - "assetInfo": { - "assetGuid": "123456789", - "value": "50000000000" - } - } - ], - "blockHash": "abc123def456789abc123def456789abc123def456789abc123def456789abc123", - "blockHeight": 1234567, - "confirmations": 10, - "blockTime": 1640995200, - "value": "9999900000", - "valueIn": "10000000000", - "fees": "100000", - "tokenType": "SPTAssetAllocationSend", - "memo": "VGVzdCBTUFQgdHJhbnNmZXI=" -} -``` - -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. - -#### Get transaction specific - -Returns transaction data in the exact format as returned by backend, including all coin specific fields: - -``` -GET /api/v2/tx-specific/ -``` - -Example response: - -```javascript -{ - "hex": "040000808...8e6e73cb009", - "txid": "7a0a0ff6f67bac2a856c7296382b69151949878de6fb0d01a8efa197182b2913", - "overwintered": true, - "version": 4, - "versiongroupid": "892f2085", - "locktime": 0, - "expiryheight": 495680, - "vin": [], - "vout": [], - "vjoinsplit": [], - "valueBalance": 0, - "vShieldedSpend": [ - { - "cv": "50258bfa65caa9f42f4448b9194840c7da73afc8159faf7358140bfd0f237962", - "anchor": "6beb3b64ecb30033a9032e1a65a68899917625d1fdd2540e70f19f3078f5dd9b", - "nullifier": "08e5717f6606af7c2b01206ff833eaa6383bb49c7451534b2e16d588956fd10a", - "rk": "36841a9be87a7022445b77f433cdd0355bbed498656ab399aede1e5285e9e4a2", - "proof": "aecf824dbae8eea863ec6...73878c37391f01df520aa", - "spendAuthSig": "65b9477cb1ec5da...1178fe402e5702c646945197108339609" - }, - { - "cv": "a5aab3721e33d6d6360eabd21cbd07524495f202149abdc3eb30f245d503678c", - "anchor": "6beb3b64ecb30033a9032e1a65a68899917625d1fdd2540e70f19f3078f5dd9b", - "nullifier": "60e790d6d0e12e777fb2b18bc97cf42a92b1e47460e1bd0b0ffd294c23232cc9", - "rk": "2d741695e76351597712b4a04d2a4e108a116f376283d2d104219b86e2930117", - "proof": "a0c2a6fdcbba966b9894...3a9c3118b76c8e2352d524cbb44c02decaeda7", - "spendAuthSig": "feea902e01eac9ebd...b43b4af6b607ce5b0b38f708" - } - ], - "vShieldedOutput": [ - { - "cv": "23db384cde862f20238a1004e57ba18f114acabc7fd2ac029757f82af5bd4cab", - "cmu": "3ff5a5ff521fabefb5287fef4feb2642d69ead5fe18e6ac717cfd76a8d4088bc", - "ephemeralKey": "057ff6e059967784fa6ac34ad9ecfd9c0c0aba743b7cd444a65ecc32192d5870", - "encCiphertext": "a533d3b99b...a0204", - "outCiphertext": "4baabc15199504b1...c1ad6a", - "proof": "aa1fb2706cba5...1ec7e81f5deea90d4f57713f3b4fc8d636908235fa378ebf1" - } - ], - "bindingSig": "bc018af8808387...5130bb382ad8e6e73cb009", - "blockhash": "0000000001c4aa394e796dd1b82e358f114535204f6f5b6cf4ad58dc439c47af", - "confirmations": 5222, - "time": 1552301566, - "blocktime": 1552301566 -} -``` - -#### Get address - -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=&filter=&contract=] -``` - -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 -- *filter*: specifies what tokens (xpub addresses/tokens) are returned by the request (default *nonzero*) - - *inputs*: Return transactions sending inputs to this xpub - - *outputs*: Return transactions sending outputs to this xpub - - *=*: Return specific numerical vout index -- *assetMask*: What type of transactions to return (default *all*) - - *all*: Returns all types of transactions, base and asset type. The assetMask will represent value of all values OR'ed together see below in *=* for the masks. - - *non-tokens*: Return only base coin transactions no asset type. The assetMask will represent value of *basecoin*. - - *token-only*: Return only asset type transactions no base coin type. The assetMask will represent value of *assetactivate* | *assetupdate* | *assetsend* | *syscoinburntoallocation* | *assetallocationburntosyscoin* | *assetallocationburntonevm* | *assetallocationmint* | *assetallocationsend*. - - *token-transfers*: Return only assetallocationsend type transactions. The assetMask will represent value of *assetallocationsend*. - - *non-token-transfers*: Return any transactions not of type assetallocationsend. The assetMask will represent value of *token-only* &^ *token-transfers* - - *=*: Apply a custom numerical mask which is a bitmask of the following values: - - *basecoin*: 1 - - *assetallocationsend*: 2 - - *syscoinburntoallocation*: 4 - - *assetallocationburntosyscoin*: 8 - - *assetallocationburntonevm*: 16 - - *assetallocationmint*: 32 - - *assetupdate*: 64 - - *assetsend*: 128 - - *assetactivate*: 256 -- *contract*: return only transactions which affect specified contract or asset (applicable only to Ethereum and Syscoin) - -Response: - -```javascript -{ - "page": 1, - "totalPages": 1, - "itemsOnPage": 1000, - "address": "D5Z7XrtJNg7hAtznSDMXvfiFmMYphwuWz7", - "balance": "2432468097999991", - "totalReceived": "3992283916999979", - "totalSent": "1559815818999988", - "unconfirmedBalance": "0", - "unconfirmedTxs": 0, - "txs": 3, - "txids": [ - "461dd46d5d6f56d765f82e60e6bf0727a3a1d1cb8c4144373d805b152a21d308", - "bdb5b47603c5d174eae3384c368068c8e9d2183b398ed0e31d125defa4447a10", - "5c1d2686d70d82bd8e84b5d3dc4bd0e8485e28cdc865336db6a5e40b2098277d" - ], - "tokens": [ - { - "type": "SPTAllocated", - "name": "sys1qk5x3j5s7n8t9k3c2x4v5b6n7m8l9p0a1s2d3f4g5h", - "assetGuid": "123456789", - "symbol": "MYTOKEN", - "decimals": 8, - "balance": "500000000000", - "totalReceived": "1000000000000", - "totalSent": "500000000000", - "transfers": 15 - } - ] -} -``` - -#### 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 - - 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 are in the form `([][//*])[#checkum]`, for example `pkh([5c9e228d/44'/0'/0']xpub6BgBgses...Mj92pReUsQ/<0;1>/*)#abcd` - - Parameters `type` and `xpub` are mandatory, the rest is optional - - Blockbook supports a limited set of `type`s: - - 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>`. - - - -The returned transactions are sorted by block height, newest blocks first. - -``` -GET /api/v2/xpub/[?page=&pageSize=&from=&to=&details=&tokens=&filter=] -``` - -The optional query parameters: -- *page*: specifies page of returned transactions, starting from 1. If out of range, Blockbook returns the closest possible page. Tokens are only returned for coins that have token platforms (Syscoin). -- *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 xpub balances, without any derived addresses and transactions - - *tokens*: *basic* + tokens (addresses/tokens) derived from the xpub, subject to *tokens* parameter - - *tokenBalances*: *basic* + tokens (addresses/tokens) derived from the xpub with balances, subject to *tokens* parameter - - *txids*: *tokenBalances* + list of txids, subject to *from*, *to* filter and paging - - *txs*: *tokenBalances* + list of transaction with details, subject to *from*, *to* filter and paging -- *tokens*: specifies what tokens (xpub addresses/tokens) are returned by the request (default *nonzero*) - - *nonzero*: return only addresses/tokens with nonzero balance - - *used*: return addresses/tokens with at least one transaction - - *derived*: return all derived addresses/tokens -- *filter*: specifies what tokens (xpub addresses/tokens) are returned by the request (default *nonzero*) - - *inputs*: Return transactions sending inputs to this xpub - - *outputs*: Return transactions sending outputs to this xpub - - *=*: Return specific numerical vout index -- *assetMask*: What type of transactions to return (default *all*) - - *all*: Returns all types of transactions, base and asset type. The assetMask will represent value of all values OR'ed together see below in *=* for the masks. - - *non-tokens*: Return only base coin transactions no asset type. The assetMask will represent value of *basecoin*. - - *token-only*: Return only asset type transactions no base coin type. The assetMask will represent value of *assetactivate* | *assetupdate* | *assetsend* | *syscoinburntoallocation* | *assetallocationburntosyscoin* | *assetallocationburntonevm* | *assetallocationmint* | *assetallocationsend*. - - *token-transfers*: Return only assetallocationsend type transactions. The assetMask will represent value of *assetallocationsend*. - - *non-token-transfers*: Return any transactions not of type assetallocationsend. The assetMask will represent value of *token-only* &^ *token-transfers* - - *=*: Apply a custom numerical mask which is a bitmask of the following values: - - *basecoin*: 1 - - *assetallocationsend*: 2 - - *syscoinburntoallocation*: 4 - - *assetallocationburntosyscoin*: 8 - - *assetallocationburntonevm*: 16 - - *assetallocationmint*: 32 - - *assetupdate*: 64 - - *assetsend*: 128 - - *assetactivate*: 256 -- *contract*: return only transactions which affect specified contract or asset (applicable only to Ethereum and Syscoin) - -Response: - -```javascript -{ - "page": 1, - "totalPages": 1, - "itemsOnPage": 1000, - "address": "dgub8sbe5Mi8LA4dXB9zPfLZW8arm...9Vjp2HHx91xdDEmWYpmD49fpoUYF", - "balance": "0", - "totalReceived": "3083381250", - "totalSent": "3083381250", - "unconfirmedBalance": "0", - "unconfirmedTxs": 0, - "txs": 5, - "txids": [ - "383ccb5da16fccad294e24a2ef77bdee5810573bb1b252d8b2af4f0ac8c4e04c", - "75fb93d47969ac92112628e39148ad22323e96f0004c18f8c75938cffb6c1798", - "e8cd84f204b4a42b98e535e72f461dd9832aa081458720b0a38db5856a884876", - "57833d50969208091bd6c950599a1b5cf9d66d992ae8a8d3560fb943b98ebb23", - "9cfd6295f20e74ddca6dd816c8eb71a91e4da70fe396aca6f8ce09dc2947839f", - ], - "usedTokens": 2, - "tokens": [ - { - "type": "XPUBAddress", - "name": "DUCd1B3YBiXL5By15yXgSLZtEkvwsgEdqS", - "path": "m/44'/3'/0'/0/0", - "transfers": 3, - "decimals": 8, - "balance": "0", - "totalReceived": "2803986975", - "totalSent": "2803986975" - }, - { - "type": "XPUBAddress", - "name": "DKu2a8Wo6zC2dmBBYXwUG3fxWDHbKnNiPj", - "path": "m/44'/3'/0'/1/0", - "transfers": 2, - "decimals": 8, - "balance": "0", - "totalReceived": "279394275", - "totalSent": "279394275" - } - ] -} -``` - -Note: *usedTokens* always returns total number of **used** addresses of xpub. - -#### Get utxo - -Returns array of unspent transaction outputs of address or xpub, applicable only for Bitcoin-type coins. By default, the list contains both confirmed and unconfirmed transactions. The query parameter *confirmed=true* disables return of unconfirmed transactions. The returned utxos are sorted by block height, newest blocks first. For xpubs or output descriptors, the response also contains address and derivation path of the utxo. - -Unconfirmed utxos do not have field *height*, the field *confirmations* has value *0* and may contain field *lockTime*, if not zero. - -Coinbase utxos have field *coinbase* set to true, however due to performance reasons only up to minimum coinbase confirmations limit (100). After this limit, utxos are not detected as coinbase. - -``` -GET /api/v2/utxo/[?confirmed=true] -``` - -Response: - -```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, - "assetInfo": { - "assetGuid": "987654321", - "value": "25000000000" - } - } -] -``` - -For Syscoin: UTXOs may include `assetInfo` objects containing SPT asset information with `assetGuid` and `value` fields for Syscoin Platform Token holdings. -``` - -#### Get block +- `http://localhost:9130/api-docs/` - Bitcoin +- `http://localhost:9116/api-docs/` - Ethereum -Returns information about block with transactions, subject to paging. +The Swagger UI is served from local pinned assets, does not use the external +Swagger validator, and has "Try it out" disabled so the docs page cannot submit +requests such as transaction broadcasts. Use the OpenAPI file with Swagger UI, +Swagger Editor, Redocly, or any OpenAPI client generator to browse REST +endpoints, schemas, examples, and the documented WebSocket request/response +envelope. -``` -GET /api/v2/block/ -``` - -Response: - -```javascript -{ - "page": 1, - "totalPages": 1, - "itemsOnPage": 1000, - "hash": "760f8ed32894ccce9c1ea11c8a019cadaa82bcb434b25c30102dd7e43f326217", - "previousBlockHash": "786a1f9f38493d32fd9f9c104d748490a070bc74a83809103bcadd93ae98288f", - "nextBlockHash": "151615691b209de41dda4798a07e62db8429488554077552ccb1c4f8c7e9f57a", - "height": 2648059, - "confirmations": 47, - "size": 951, - "time": 1553096617, - "version": 6422787, - "merkleRoot": "6783f6083788c4f69b8af23bd2e4a194cf36ac34d590dfd97e510fe7aebc72c8", - "nonce": "0", - "bits": "1a063f3b", - "difficulty": "2685605.260733312", - "txCount": 2, - "txs": [ - { - "txid": "2b9fc57aaa8d01975631a703b0fc3f11d70671953fc769533b8078a04d029bf9", - "vin": [ - { - "n": 0, - "value": "0" - } - ], - "vout": [ - { - "value": "1000100000000", - "n": 0, - "addresses": [ - "D6ravJL6Fgxtgp8k2XZZt1QfUmwwGuLwQJ" - ], - "isAddress": true - } - ], - "blockHash": "760f8ed32894ccce9c1ea11c8a019cadaa82bcb434b25c30102dd7e43f326217", - "blockHeight": 2648059, - "confirmations": 47, - "blockTime": 1553096617, - "value": "1000100000000", - "valueIn": "0", - "fees": "0" - }, - { - "txid": "d7ce10ecf9819801ecd6ee045cbb33436eef36a7db138206494bacedfd2832cf", - "vin": [ - { - "n": 0, - "addresses": [ - "9sLa1AKzjWuNTe1CkLh5GDYyRP9enb1Spp" - ], - "isAddress": true, - "value": "1277595845202" - } - ], - "vout": [ - { - "value": "9900000000", - "n": 0, - "addresses": [ - "DMnjrbcCEoeyvr7GEn8DS4ZXQjwq7E2zQU" - ], - "isAddress": true - }, - { - "value": "1267595845202", - "n": 1, - "spent": true, - "addresses": [ - "9sLa1AKzjWuNTe1CkLh5GDYyRP9enb1Spp" - ], - "isAddress": true - } - ], - "blockHash": "760f8ed32894ccce9c1ea11c8a019cadaa82bcb434b25c30102dd7e43f326217", - "blockHeight": 2648059, - "confirmations": 47, - "blockTime": 1553096617, - "value": "1277495845202", - "valueIn": "1277595845202", - "fees": "100000000" - } - ] -} -``` -_Note: Blockbook always follows the main chain of the backend it is attached to. If there is a rollback-reorg in the backend, Blockbook will also do rollback. When you ask for block by height, you will always get the main chain block. If you ask for block by hash, you may get the block from another fork but it is not guaranteed (backend may not keep it)_ - -#### Get asset - -Returns asset details and transactions for a given asset GUID (applicable only to Syscoin). - -``` -GET /api/v2/asset/[?page=&pageSize=&from=&to=&details=&filter=] -``` - -The optional query parameters are the same as for address endpoint. - -Response: - -```javascript -{ - "page": 1, - "totalPages": 1, - "itemsOnPage": 1000, - "asset": { - "assetGuid": "123456789", - "contract": "0x742d35Cc63C4Ec4C670f1c96e2e6eF0f3a2C0F8f", - "symbol": "MYTOKEN", - "totalSupply": "1000000000000000000", - "maxSupply": "5000000000000000000", - "decimals": 8, - "metaData": "ERC20 Token" - }, - "unconfirmedTxs": 0, - "unconfirmedBalance": "0", - "txs": 42, - "txids": [ - "abc123...", - "def456..." - ] -} -``` - -#### Get assets - -Returns filtered list of assets matching the search criteria (applicable only to Syscoin). - -``` -GET /api/v2/assets/[?page=&pageSize=] -``` - -Response: - -```javascript -{ - "page": 1, - "totalPages": 1, - "itemsOnPage": 1000, - "numAssets": 150, - "assets": [ - { - "assetGuid": "123456789", - "contract": "0x742d35Cc63C4Ec4C670f1c96e2e6eF0f3a2C0F8f", - "symbol": "MYTOKEN", - "totalSupply": "1000000000000000000", - "precision": 8, - "txs": 42, - "metaData": "ERC20 Token" - } - ] -} -``` - -#### Send transaction - -Sends new transaction to backend. - -``` -GET /api/v2/sendtx/ -POST /api/v2/sendtx/ (hex tx data in request body) NB: the '/' symbol at the end is mandatory. -``` - -Response: - -```javascript -{ - "result": "7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25" -} -``` - -or in case of error - -```javascript -{ - "error": { - "message": "error message" - } -} -``` - -#### Tickers list - -Returns a list of available currency rate tickers for the specified date, along with an actual data timestamp. - -``` -GET /api/v2/tickers-list/?timestamp= -``` - -The query parameters: -- *timestamp*: specifies a Unix timestamp to return available tickers for. - -Example response: - -```javascript -{ - "ts":1574346615, - "available_currencies": [ - "eur", - "usd" - ] -} -``` - -#### Tickers +For local validation and generated TypeScript smoke tests, use the OpenAPI test +harness: -Returns currency rate for the specified currency and date. If the currency is not available for that specific timestamp, the next closest rate will be returned. -All responses contain an actual rate timestamp. - -``` -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. - -Example response (no parameters): - -```javascript -{ - "ts": 1574346615, - "rates": { - "eur": 7134.1, - "usd": 7914.5 - } -} -``` - -Example response (currency=usd): - -```javascript -{ - "ts": 1574346615, - "rates": { - "usd": 7914.5 - } -} -``` - -Example error response (e.g. rate unavailable, incorrect currency...): -```javascript -{ - "ts":7980386400, - "rates": { - "usd": -1 - } -} -``` - -#### Balance history - -Returns a balance history for the specified XPUB or address. - -``` -GET /api/v2/balancehistory/?from=&to=[&fiatcurrency=&groupBy= -``` - -Query parameters: -- *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 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): -```javascript -[ - { - "time": 1578391200, - "txs": 5, - "received": "5000000", - "sent": "0", - "rates": { - "usd": 7855.9, - "eur": 6838.13, - ... - } - }, - { - "time": 1578488400, - "txs": 1, - "received": "0", - "sent": "5000000", - "rates": { - "usd": 8283.11, - "eur": 7464.45, - ... - } - } -] -``` - -Example response (fiatcurrency not specified): -```javascript -[ - { - "time": 1578391200, - "txs": 5, - "received": "5000000", - "sent": "0", - "sentToSelf":"100000", - "rates": { - "usd": 7855.9, - "eur": 6838.13, - ... - } - }, - { - "time": 1578488400, - "txs": 1, - "received": "0", - "sent": "5000000", - "sentToSelf":"0", - "rates": { - "usd": 8283.11, - "eur": 7464.45, - ... - } - } -] +```sh +contrib/tests/run-openapi-tests.sh ``` -Example response (fiatcurrency=usd): - -```javascript -[ - { - "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 - } - } -] -``` - -Example response (fiatcurrency=usd&groupBy=172800): - -```javascript -[ - { - "time": 1578355200, - "txs": 6, - "received": "5000000", - "sent": "5000000", - "sentToSelf":"0", - "rates": { - "usd": 7734.45 - } - } -] -``` - -The value of `sentToSelf` is the amount sent from the same address to the same address or within addresses of xpub. - -### Websocket API - -Websocket interface is provided at `/websocket/`. The interface can be explored using Blockbook Websocket Test Page found at `/test-websocket.html`. - -The websocket interface provides the following requests: - -- getInfo -- getBlockHash -- getAccountInfo -- getAccountUtxo -- getTransaction -- getTransactionSpecific -- getAsset (Syscoin only) -- getAssets (Syscoin only) -- getBalanceHistory -- getCurrentFiatRates -- getFiatRatesTickersList -- getFiatRatesForTimestamps -- 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 given address (list of addresses) -- `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. - -The subscribeNewTransaction event is not enabled by default. To enable support, blockbook must be run with the `-enablesubnewtx` flag. - -_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 -``` -{ - "id":"1", //an id to help to identify the response - "method":"", - "params": -} -``` - -Example for subscribing to an address (or multiple addresses) -``` -{ - "id":"1", - "method":"subscribeAddresses", - "params":{ - "addresses":["mnYYiDCb2JZXnqEeXta1nkt5oCVe2RVhJj", "tb1qp0we5epypgj4acd2c4au58045ruud2pd6heuee"] - } -} -``` +The legacy API V1 is kept only for Bitcoin-type compatibility and is not being +extended. New integrations should use API V2. diff --git a/docs/build.md b/docs/build.md index 094e5d98c9..0d4a7e50bf 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 @@ -78,7 +78,7 @@ There are few variables that can be passed to `make` in order to modify build pr `BASE_IMAGE`: Specifies the base image of the Docker build image. By default, it chooses the same Linux distro as the host machine but you can override it this way `make BASE_IMAGE=debian:10 all-bitcoin` to make a build for Debian 10. -*Please be aware that we are running our Blockbooks on Debian 9 and Debian 10 and do not offer support with running it on other distros.* +*Please be aware that we are currently running our Blockbooks on Debian 11 and do not offer support with running it on other distros.* `NO_CACHE`: Common behaviour of Docker image build is that build steps are cached and next time they are executed much faster. Although this is a good idea, when something went wrong you will need to override this behaviour somehow. Execute this @@ -86,6 +86,38 @@ command: `make NO_CACHE=true all-bitcoin`. `TCMALLOC`: RocksDB, the storage engine used by Blockbook, allows to use alternative memory allocators. Use the `TCMALLOC` variable to specify Google's TCMalloc allocator `make TCMALLOC=true all-bitcoin`. To run Blockbook built with TCMalloc, the library must be installed on the target server, for example by `sudo apt-get install google-perftools`. +`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`. +Generated dev Blockbook services include `-prof=:` automatically, while generated prod +services do not include `-prof`. + +`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*. @@ -135,7 +167,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*. @@ -151,7 +183,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 @@ -183,13 +215,13 @@ Configuration is described in [config.md](/docs/config.md). ## Manual build -Instructions below are focused on Debian 9 (Stretch) and 10 (Buster). If you want to use another Linux distribution or operating system -like macOS or Windows, please read instructions specific for each project. +Instructions below are focused on Debian 11 on amd64. If you want to use another Linux distribution or operating system +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.17.1.linux-amd64.tar.gz && tar xf go1.17.1.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 @@ -199,22 +231,23 @@ export PATH=$PATH:$GOPATH/bin ``` Install RocksDB: https://github.com/facebook/rocksdb/blob/master/INSTALL.md -and compile the static_lib and tools +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 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 v6.22.1 -CFLAGS=-fPIC CXXFLAGS=-fPIC make release +git checkout v9.10.0 +CFLAGS=-fPIC CXXFLAGS="-fPIC -Wno-error=array-bounds" make release ``` -Setup variables for gorocksdb +Setup variables for grocksdb ``` export CGO_CFLAGS="-I/path/to/rocksdb/include" -export CGO_LDFLAGS="-L/path/to/rocksdb -lrocksdb -lstdc++ -lm -lz -ldl -lbz2 -lsnappy -llz4" +export CGO_LDFLAGS="-L/path/to/rocksdb -lrocksdb -lstdc++ -lm -lz -ldl -lbz2 -lsnappy -llz4 -lzstd" ``` Install ZeroMQ: https://github.com/zeromq/libzmq @@ -232,9 +265,9 @@ Get blockbook sources, install dependencies, build: ``` cd $GOPATH/src -git clone https://github.com/syscoin/blockbook.git +git clone https://github.com/trezor/blockbook.git cd blockbook -go build -tags rocksdb_6_16 +go build ``` ### Example command @@ -255,7 +288,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..0baf5b00e0 --- /dev/null +++ b/docs/ci_cd.md @@ -0,0 +1,235 @@ +# 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`. +Generated dev Blockbook services start with pprof enabled on `:`, for example Ethereum +uses `:29036`. Generated prod services do not include `-prof`. + +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..d8b8ccb3d0 --- /dev/null +++ b/docs/env.md @@ -0,0 +1,126 @@ +# 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. + +Blockbook reads these from its process environment. When installed from the Debian package, the systemd unit loads them from an optional `EnvironmentFile=-/etc/blockbook/blockbook.env` (one `KEY=value` per line). The file is optional: if it is absent the service starts normally and any variables provided by other means (e.g. systemd `DefaultEnvironment`) still apply. The file is read by the service manager at startup, so it only needs to be readable by root. + +- `BB_ADMIN_USER` / `BB_ADMIN_PASSWORD` - **Global (not coin-prefixed).** HTTP Basic-auth credentials required to reach the internal server's `/admin` endpoints (the administrative pages and the state-mutating POST handlers such as internal-data refetch and contract-info updates). Basic auth is used so the admin pages and forms work directly in a browser via its native login prompt (and `curl -u user:pass` for scripts). The admin surface is **fail-closed**: unless **both** variables are set, every `/admin` route returns `503` and the endpoints are unusable; `/metrics`, the status page (`/`) and static assets are unaffected. A request with missing or wrong credentials gets `401`. Leading/trailing whitespace in either value is ignored, so a stray space or newline in `blockbook.env` will not lock you out. The internal server binds all interfaces by default (`internal_binding_template` is `:`), so set these on every host. Note that the packaged service serves the internal port over HTTPS using the **bundled self-signed certificate** (`cert/blockbook.{crt,key}`, symlinked to the repo's `testcert`), whose private key is public — so that TLS protects the credentials against passive sniffing but not against an active man-in-the-middle. This is acceptable on a trusted, firewalled internal segment (the intended deployment); from a shell you can reach it as e.g. `curl -k -u "$BB_ADMIN_USER:$BB_ADMIN_PASSWORD" https://host:/admin/...`. If the internal network is not trusted, terminate real TLS at a reverse proxy and/or restrict the internal port to trusted peers. Do not expose it directly to the internet. + +- `_WS_GETACCOUNTINFO_LIMIT` - Limits the number of `getAccountInfo` requests per websocket connection to reduce server abuse. Accepts number as input. + +- `_WS_BALANCE_HISTORY_MAX_TXS` / `_REST_BALANCE_HISTORY_MAX_TXS` - Maximum number of transactions a single balance-history request (for an address or an xpub) may aggregate, set independently for the WebSocket `getBalanceHistory` method and the REST `/api/v2/balancehistory/...` endpoint. Each aggregated transaction costs a database read, so an unbounded request over an address or xpub with a very large history (e.g. an exchange address) is a cheap-to-send, expensive-to-serve request. Past the cap the request is rejected with `400` and a message asking to narrow the `from`/`to` range, rather than returning a truncated (and therefore wrong) history. Accepts a non-negative integer; `0` disables the cap. + + The WebSocket cap defaults to `1000000` and the REST cap to `250000`. The split exists because Trezor Suite talks to Blockbook over WebSocket only, requests the full account history for its balance graph (no `from`/`to` window), and never derives the displayed balance from balance history — so the WS cap is generous enough not to break even a heavy wallet's graph, while still bounding a single expensive message. The REST API is an open, unauthenticated surface, so its cap is tighter. Neither default affects a normal wallet; raise a cap only if you serve balance history for genuinely high-volume addresses on that surface. + +- `_BALANCE_HISTORY_MAX_TXS` - Backward-compatible shared fallback that sets the default for **both** the WS and REST caps above. A transport-specific variable, when set, overrides this for its surface. Accepts a non-negative integer; `0` disables the cap. Unset, the per-surface defaults (`1000000` WS / `250000` REST) apply. + +- `_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). + +- `_TRUSTED_PROXIES` - Comma-separated list of trusted proxy CIDRs whose `X-Real-Ip` header should be used as the client IP for public REST API and WebSocket rate limiting. + Blockbook always trusts `X-Real-Ip` from loopback and RFC1918/private peers, so this variable is only needed for additional non-local proxies. This implicit trust assumes the private network segment in front of Blockbook is not attacker-reachable. Link-local peers (`fe80::/10`) are **not** implicitly trusted — a link-local source address is reachable and forgeable by any node on the same link — so a link-local proxy must be listed explicitly here (e.g. `fe80::1/128`). + + If this variable and its legacy alias are unset, Blockbook keeps the default Cloudflare behavior and uses `CF-Connecting-IP` as the client IP when it contains a valid address. `CF-Connecting-IP` is the only `cf-*` request header Cloudflare always overwrites with the verified visitor IP; `CF-Connecting-IPv6` is forwarded unchanged from the client unless the Cloudflare zone runs "Pseudo IPv4: Overwrite Headers", so it is ignored by default and honored only when `_CLOUDFLARE_PSEUDO_IPV4` is set (see below). Whether `CF-Connecting-IP` is trusted from any peer or only from verified Cloudflare peers is controlled by `_CLOUDFLARE_IPS` (see below). + + 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`. + + For backwards compatibility, `_WS_TRUSTED_PROXIES` is accepted as a legacy alias when `_TRUSTED_PROXIES` is unset. Prefer the shared variable because the same client attribution is used by REST and WebSocket limiters. + +- `_CLOUDFLARE_IPS` - Controls how the `CF-Connecting-IP` / `CF-Connecting-IPv6` headers are trusted in the default (no `_TRUSTED_PROXIES`) mode. Because those headers are client-settable, they are only meaningful if the origin can prove the connection actually came from Cloudflare. + - Unset with no legacy alias, or `builtin` (default): Blockbook trusts the `CF-Connecting-*` headers only when the TCP peer is inside Cloudflare's published edge ranges (loaded at startup from `server/cloudflare_ips.txt` compiled into the binary, as of 2026-06) or is a loopback/private proxy fronting Cloudflare. A direct public non-Cloudflare peer cannot spoof a client IP past the per-IP limiter or the IP blocklist. + - `@/path/to/file`: load the ranges from the given file at startup instead of the compiled-in list (one CIDR per line; blank lines, commas, and `#` comments are allowed). Useful to track Cloudflare's published ranges without rebuilding. An unreadable file or a file with no CIDRs fails startup. + - A comma-separated CIDR list: use these ranges instead of the built-in list (for example if Cloudflare's ranges drift, or for a custom front-end CDN). Loopback/RFC1918 peers are always also accepted (link-local peers are not — list them explicitly in `_TRUSTED_PROXIES` if a link-local proxy fronts Cloudflare). A value that contains no valid CIDRs fails startup rather than silently disabling verification; only the explicit `off` spellings disable it. + - `off` (or `none`/`false`/`0`): disable verification and trust `CF-Connecting-*` from any peer (the historical behavior). Only safe when the origin is firewalled to Cloudflare ranges out of band. With verification off, the IP auto-block never acts on a `CF-Connecting-*`-derived address (it would be spoofable), so it only blocks direct TCP peers. + + For backwards compatibility, `_WS_CLOUDFLARE_IPS` is accepted as a legacy alias when `_CLOUDFLARE_IPS` is unset. An explicit shared value, including `off`, does not fall back. + + Note that a loopback/private peer counts as "a proxy fronting Cloudflare": `CF-Connecting-*` headers forwarded by a local reverse proxy are trusted and treated as block-safe. If your deployment is NOT behind Cloudflare and a local proxy forwards client-supplied headers verbatim, strip or overwrite `CF-Connecting-IP` and `CF-Connecting-IPv6` at the proxy; otherwise a client could spoof its attribution (and, with the IP auto-block enabled, get an innocent visitor's address blocked). + + Even with verification on, only `CF-Connecting-IP` is trusted by content. Cloudflare overwrites only that one `cf-*` request header; it forwards every other client-supplied `cf-*` header (including `CF-Connecting-IPv6`) to the origin unchanged, so Blockbook never trusts any other `cf-*` header by content unless explicitly opted in via `_CLOUDFLARE_PSEUDO_IPV4`. + +- `_CLOUDFLARE_PSEUDO_IPV4` - Boolean (default `false`). When `true`, Blockbook honors the `CF-Connecting-IPv6` header as the client IP (preferred over `CF-Connecting-IP`, which in this mode holds a synthetic pseudo-IPv4). Enable this **only** if the Cloudflare zone has "Pseudo IPv4: Overwrite Headers" turned on. In that mode Cloudflare sets (and therefore sanitizes) `CF-Connecting-IPv6`; in every other mode `CF-Connecting-IPv6` is a client-spoofable header and enabling this would let any client forge its attributed IP — getting an innocent visitor rate-limited or blocked, or evading the limiter by rotating the value. Leave unset for normal deployments. As defense-in-depth, operators can also add a Cloudflare Managed Transform / request-header rule that strips an incoming `CF-Connecting-IPv6` at the edge, but the code default is already safe without it. + +- `_WS_MESSAGE_RATE_LIMIT` - Maximum number of messages a single WebSocket connection may send within `_WS_MESSAGE_RATE_WINDOW` before the connection is closed and (when blockable) its client key is added to the IP blocklist. Accepts a non-negative integer; default `2500`, `0` disables the per-connection message rate limit entirely. The default of 2500 messages / 10 minutes is above the maximum burst produced by a Trezor Suite client, so it only trips clearly abusive traffic. + +- `_WS_MESSAGE_RATE_WINDOW` - Trailing sliding window for `_WS_MESSAGE_RATE_LIMIT`, as a Go duration string (e.g. `10m`, `600s`). Default `10m`. + +- `_WS_IP_BLOCK_DURATION` - How long a client key (an IPv4 address, or a full IPv6 `/128` address) is blocked from opening new WebSocket connections after a connection trips the message rate limit, as a Go duration string (e.g. `12h`). Default `12h`; `0` disables IP blocking (an offending connection is still closed). The block key is intentionally narrower than the rate-limit key (which aggregates IPv6 to its `/64`): a hard block keys on the individual `/128` so it cannot deny service to an entire shared `/64`, while the connection rate limiter still aggregates to `/64` so an abuser cannot dodge limits by rotating addresses within an owned `/64`. The blocklist is visible at `/admin/ws-limit-exceeding-ips`, and the `blockbook_websocket_blocked_ips` / `blockbook_websocket_blocked_connections` metrics track it. + + Blocking keys on the same client IP attribution as the per-IP limiter. Loopback/private/link-local addresses and any configured trusted-proxy or Cloudflare edge range are never blocked, so a misconfiguration that collapses many clients onto a shared address cannot block them all. Behind Cloudflare, keep `_CLOUDFLARE_IPS` at its default (or set it to your CDN ranges) so blocks key on the real visitor IP. The default Cloudflare peer verification plus a block-safety guard (a `CF-Connecting-*`-derived address is only blocked when the peer was verified as Cloudflare or is a local proxy fronting Cloudflare) prevent a direct public peer from using a forged `CF-Connecting-IP` to block an innocent visitor; if a local proxy forwards client headers and the deployment is not behind Cloudflare, strip `CF-Connecting-*` at the proxy (see `_CLOUDFLARE_IPS`). + + The blocklist is kept in memory only: a restart clears all active blocks. A blocked client key still blocks every other client sharing that exact key — the same IPv4 address behind CGNAT — but IPv6 blocks now key on the individual `/128`, so unrelated neighbors sharing a `/64` are no longer caught by one address's block. + +- `_REST_UI_RATE_LIMIT` - Maximum number of public HTTP requests a single client key may start within `_REST_UI_RATE_WINDOW`. Accepts a non-negative integer; default `180`, `0` disables request-rate limiting. The client key uses the shared REST/WebSocket attribution rules: an IPv4 address, or an IPv6 `/64` prefix. + + Although it is configured through `REST_UI_*` variables, this limiter governs **all dynamic public routes under one shared per-client budget** — both the explorer UI pages (`/address/`, `/xpub/`, `/tx/`, `/search/`, `/block/`, …) and the REST API (`/api/...`). Only static assets (`/static/`, `/favicon.ico`, `/test-websocket.html`), the API docs (`/api-docs`), the OpenAPI spec (`/openapi.yaml`), and the WebSocket endpoint (`/websocket`, which has its own limiter — see `_WS_MESSAGE_RATE_LIMIT`) are exempt. New routes are covered automatically. + + Requests arriving directly from a loopback/private peer (or a configured trusted proxy) **without** a client attribution header are exempt from all REST limits: such a key identifies the operator's own tooling or a proxy that does not forward the client IP (`X-Real-Ip` unset), and limiting it would throttle a whole deployment as one client. To rate limit traffic behind a reverse proxy, configure the proxy to set `X-Real-Ip` (and list it in `_TRUSTED_PROXIES` if it is not on a local network). + +- `_REST_UI_RATE_WINDOW` - Token-bucket refill window for `_REST_UI_RATE_LIMIT`, as a Go duration string (e.g. `1m`, `60s`). Default `1m`. + +- `_REST_UI_BURST` - Token-bucket burst size for one client key. Default `40`; must be positive when request-rate limiting is enabled. + +- `_REST_UI_MAX_CONCURRENT` - Maximum number of in-flight public HTTP requests (explorer UI page or REST API call) accepted from one client key. Default `12`; `0` disables the per-client concurrency limit. This protects slow or expensive handlers held open concurrently from one source. + +- `_REST_UI_STATE_TTL` - How long idle limiter state is retained for one client key, as a Go duration string. Default `10m`. + +- `_REST_UI_BLOCK_DURATION` - Optional temporary block duration for a client key after repeated rate/concurrency breaches, as a Go duration string. Default `0`, which disables temporary blocking and only returns `429 Too Many Requests` while over the configured limits. Breaches count as separate episodes only when at least 10 seconds apart, so a single burst (for example one page firing dozens of parallel requests) cannot trip the block. As with the WebSocket block, the temporary block keys on the individual address (IPv4, or the full IPv6 `/128`) while request-rate limiting still aggregates IPv6 to its `/64`, so a block cannot deny service to an entire shared `/64`. When enabled, loopback/private/link-local addresses and configured trusted-proxy or Cloudflare edge ranges are never blocked, and a `CF-Connecting-*`-derived address is blockable only when the TCP peer was verified as Cloudflare. The `blockbook_rest_ui_rate_limit_rejections`, `blockbook_rest_ui_active_ips`, `blockbook_rest_ui_max_active_requests_per_ip`, and `blockbook_rest_ui_blocked_ips` metrics track the limiter. + +- `_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. Cached transactions are periodically checked with the alternative provider's `eth_getTransactionByHash`; an empty response or mined transaction removes the local pending entry. 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 25a36fbd1a..6ca5c7982c 100644 --- a/docs/ports.md +++ b/docs/ports.md @@ -1,64 +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 | -| Zcoin | 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 | -| ZelCash | 9058 | 9158 | 8058 | 38358 | -| Ravencoin | 9059 | 9159 | 8059 | 38359 | -| Ritocoin | 9060 | 9160 | 8060 | 38360 | -| Decred | 9061 | 9161 | 8061 | 38361 | -| 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 | -| Syscoin | 9093 | 9193 | 8092 | 38393 | -| Omotenashicoin | 9094 | 9194 | 8094 | 38394 | -| BitZeny | 9095 | 9195 | 8095 | 38395 | -| 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 | -| Syscoin Testnet | 19035 | 19135 | 18035 | 48335 | -| Ethereum Ropsten | 19036 | 19136 | 18036 | 48336 p2p | -| Vertcoin Testnet | 19040 | 19140 | 18040 | 48340 | -| Monacoin Testnet | 19041 | 19141 | 18041 | 48341 | -| Groestlcoin Testnet | 19045 | 19145 | 18045 | 48345 | -| 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 | -| OmotenashicoinTestnet| 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 | | +| 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 | +| Syscoin | 9193 | 9093 | 8092 | 38393 | +| 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 | +| Ethereum Testnet Hoodi | 19106 | 19006 | 18006 | 18106 http, 18506 authrpc, 48306 p2p | +| Bitcoin Signet | 19120 | 19020 | 18020 | 48320 | +| Bitcoin Regtest | 19121 | 19021 | 18021 | 48321 | +| Ethereum Testnet Hoodi Archive | 19126 | 19026 | 18026 | 18126 http, 18126 torrent, 18526 authrpc, 48326 p2p | +| Bitcoin Testnet4 | 19129 | 19029 | 18029 | 48329 | +| Bitcoin Testnet | 19130 | 19030 | 18030 | 48330 | +| Bitcoin Cash Testnet | 19131 | 19031 | 18031 | 48331 | +| Zcash Testnet | 19132 | 19032 | 18032 | | +| 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 | +| Syscoin Testnet | 19167 | 19067 | 18067 | 48367 | +| 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 | -> 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 583162bd91..0e95606443 100644 --- a/docs/rocksdb.md +++ b/docs/rocksdb.md @@ -2,126 +2,263 @@ **Blockbook** stores data the key-value store [RocksDB](https://github.com/facebook/rocksdb/wiki). As there are multiple indexes, Blockbook uses RocksDB **column families** feature to store indexes separately. ->The database structure is described in golang pseudo types in the form *(name type)*. +> The database structure is described in golang pseudo types in the form _(name type)_. > ->Operators used in the description: ->- *->* mapping from key to value. ->- *\+* concatenation, ->- *[]* array +> Operators used in the description: > ->Types used in the description: ->- *[]byte* - variable length array of bytes ->- *[32]byte* - fixed length array of bytes (32 bytes long in this case) ->- *uint32* - unsigned integer, stored as array of 4 bytes in big endian* ->- *vint*, *vuint* - variable length signed/unsigned int ->- *addrDesc* - address descriptor, abstraction of an address. -For Bitcoin type coins it is the transaction output script, stored as variable length array of bytes. -For Ethereum type coins it is fixed size array of 20 bytes. ->- *bigInt* - unsigned big integer, stored as length of the array (1 byte) followed by array of bytes of big int, i.e. *(int_len byte)+(int_value []byte)*. Zero is stored as one byte of value 0. +> - _->_ mapping from key to value. +> - _\+_ concatenation, +> - _[]_ array +> +> Types used in the description: +> +> - _[]byte_ - variable length array of bytes +> - _[32]byte_ - fixed length array of bytes (32 bytes long in this case) +> - _uint32_ - unsigned integer, stored as array of 4 bytes in big endian\* +> - _vint_, _vuint_ - variable length signed/unsigned int +> - _addrDesc_ - address descriptor, abstraction of an address. +> For Bitcoin type coins it is the transaction output script, stored as variable length array of bytes. +> For Ethereum type coins it is fixed size array of 20 bytes. +> - _bigInt_ - unsigned big integer, stored as length of the array (1 byte) followed by array of bytes of big int, i.e. _(int_len byte)+(int_value []byte)_. Zero is stored as one byte of value 0. +> - _float32_ - float32 number stored as _uint32_ +> - string - string stored as `(len vuint)+(value []byte)` **Database structure:** -The database structure described here is of Blockbook version **0.3.6** (internal data format version 5). +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: -The database structure for **Bitcoin type** and **Ethereum type** coins is slightly different. Column families used for both types: -- default, height, addresses, transactions, blockTxs +- default, height, addresses, transactions, blockTxs, fiatRates Column families used only by **Bitcoin type** coins: + - addressBalance, txAddresses Column families used only by **Ethereum type** coins: -- addressContracts + +- addressContracts, internalData, contracts, functionSignatures, blockInternalDataErrors, addressAliases **Column families description:** - **default** - Stores internal state in json format, under the key *internalState*. - + Stores internal state in json format, under the key _internalState_. + Most important internal state values are: + - coin - which coin is indexed in DB - - data format version - currently 5 + - data format version - currently 6 - dbState - closed, open, inconsistent - + Blockbook is checking on startup these values and does not allow to run against wrong coin, data format version and in inconsistent state. The database must be recreated if the internal state does not match. -- **height** +- **height** - Maps *block height* to *block hash* and additional data about block. - ``` - (height uint32) -> (hash [32]byte)+(time uint32)+(nr_txs vuint)+(size vuint) - ``` + Maps _block height_ to _block hash_ and additional data about block. + + ``` + (height uint32) -> (hash [32]byte)+(time uint32)+(nr_txs vuint)+(size vuint) + ``` - **addresses** - Maps *addrDesc+block height* to *array of transactions with array of input/output indexes*. - - The *block height* in the key is stored as bitwise complement ^ of the height to sort the keys in the order from newest to oldest. - - As there can be multiple inputs/outputs for the same address in one transaction, each txid is followed by variable length array of input/output indexes. - The index values in the array are multiplied by two, the last element of the array has the lowest bit set to 1. - Input or output is distinguished by the sign of the index, output is positive, input is negative (by operation bitwise complement ^ performed on the number). - ``` - (addrDesc []byte)+(^height uint32) -> []((txid [32]byte)+[](index vint)) - ``` + Maps _addrDesc+block height_ to _array of transactions with array of input/output indexes_. + + The _block height_ in the key is stored as bitwise complement ^ of the height to sort the keys in the order from newest to oldest. + + As there can be multiple inputs/outputs for the same address in one transaction, each txid is followed by variable length array of input/output indexes. + The index values in the array are multiplied by two, the last element of the array has the lowest bit set to 1. + Input or output is distinguished by the sign of the index, output is positive, input is negative (by operation bitwise complement ^ performed on the number). + + ``` + (addrDesc []byte)+(^height uint32) -> []((txid [32]byte)+[](index vint)) + ``` - **addressBalance** (used only by Bitcoin type coins) - Maps *addrDesc* to *number of transactions*, *sent amount*, *total balance* and a list of *unspent transactions outputs (UTXOs)*, ordered from oldest to newest - ``` - (addrDesc []byte) -> (nr_txs vuint)+(sent_amount bigInt)+(balance bigInt)+ - []((txid [32]byte)+(vout vuint)+(block_height vuint)+(amount bigInt)) - ``` + Maps _addrDesc_ to _number of transactions_, _sent amount_, _total balance_ and a list of _unspent transactions outputs (UTXOs)_, ordered from oldest to newest + + ``` + (addrDesc []byte) -> (nr_txs vuint)+(sent_amount bigInt)+(balance bigInt)+ + []((txid [32]byte)+(vout vuint)+(block_height vuint)+(amount bigInt)) + ``` - **txAddresses** (used only by Bitcoin type coins) - Maps *txid* to *block height* and array of *input addrDesc* with *amounts* and array of *output addrDesc* with *amounts*, with flag if output is spent. In case of spent output, *addrDesc_len* is negative (negative sign is achieved by bitwise complement ^). - ``` - (txid []byte) -> (height vuint)+ - (nr_inputs vuint)+[]((addrDesc_len vuint)+(addrDesc []byte)+(amount bigInt))+ - (nr_outputs vuint)+[]((addrDesc_len vint)+(addrDesc []byte)+(amount bigInt)) - ``` + Maps _txid_ to _block height_ and array of _input addrDesc_ with _amounts_ and array of _output addrDesc_ with _amounts_, with flag if output is spent. In case of spent output, _addrDesc_len_ is negative (negative sign is achieved by bitwise complement ^). + + ``` + (txid []byte) -> (height vuint)+ + (nr_inputs vuint)+[]((addrDesc_len vuint)+(addrDesc []byte)+(amount bigInt))+ + (nr_outputs vuint)+[]((addrDesc_len vint)+(addrDesc []byte)+(amount bigInt)) + ``` - **addressContracts** (used only by Ethereum type coins) - Maps *addrDesc* to *total number of transactions*, *number of non token transactions* and array of *contracts* with *number of transfers* of given address. - ``` - (addrDesc []byte) -> (total_txs vuint)+(non-token_txs vuint)+[]((contractAddrDesc []byte)+(nr_transfers vuint)) - ``` + Maps _addrDesc_ to _total number of transactions_, _number of non contract transactions_, _number of internal transactions_ + and array of _contracts_ with _number of transfers_ of given address. + + ``` + (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_. + + ``` + (txid []byte) -> (type+2*nr_transfers vuint)+<(addrDesc []byte) if CREATE>+ + []((type byte)+(fromAddrDesc []byte)+(toAddrDesc []byte)+(value bigInt))+ + (error []byte) + ``` - **blockTxs** - Maps *block height* to data necessary for blockchain rollback. Only last 300 (by default) blocks are kept. - The content of value data differs for Bitcoin and Ethereum types. + Maps _block height_ to data necessary for blockchain rollback. Only last 300 (by default) blocks are kept. + The content of value data differs for Bitcoin and Ethereum types. - - Bitcoin type + - Bitcoin type - The value is an array of *txids* and *input points* in the block. - ``` - (height uint32) -> []((txid [32]byte)+(nr_inputs vuint)+[]((txid [32]byte)+(index vint))) - ``` + The value is an array of _txids_ and _input points_ in the block. - - Ethereum type - - The value is an array of transaction data. For each transaction is stored *txid*, - *from* and *to* address descriptors and array of *contract address descriptors* with *transfer address descriptors*. - ``` - (height uint32) -> []((txid [32]byte)+(from addrDesc)+(to addrDesc)+(nr_contracts vuint)+[]((contract addrDesc)+(addr addrDesc))) - ``` + ``` + (height uint32) -> []((txid [32]byte)+(nr_inputs vuint)+[]((txid [32]byte)+(index vint))) + ``` + + - Ethereum type + + The value is an array of transaction data. For each transaction is stored _txid_, + _from_ and _to_ address descriptors and array of contract transfer infos consisting of + _from_, _to_ and _contract_ address descriptors, _type (ERC20 0 | ERC721 1 | ERC1155 2)_ and value (or list of id+value for ERC1155) + + ``` + (height uint32) -> []( + (txid [32]byte)+(from addrDesc)+(to addrDesc)+(nr_contracts vuint)+ + []((from addrDesc)+(to addrDesc)+(contract addrDesc)+(type byte)+ + <(value bigInt) if ERC20 or ERC721> or + <(nr_values vuint)+[]((id bigInt)+(value bigInt)) if ERC1155>) + ) + ``` - **transactions** - Transaction cache, *txdata* is generated by coin specific parser function PackTx. - ``` - (txid []byte) -> (txdata []byte) - ``` + Transaction cache, _txdata_ is generated by coin specific parser function PackTx. + + ``` + (txid []byte) -> (txdata []byte) + ``` - **fiatRates** - Stores fiat rates in json format. + Stored daily fiat rates, one day as one entry. + + ``` + (timestamp YYYYMMDDhhmmss) -> (nr_currencies vuint)+[]((currency string)+(rate float32))+ + (nr_tokens vuint)+[]((tokenContract string)+(tokenRate float32)) + ``` + +- **contracts** (used only by Ethereum type coins) + + 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. + ``` - (timestamp YYYYMMDDhhmmss) -> (rates json) + 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/. + + ``` + (fourBytes uint32)+(id uint32) -> (signatureName string)+[]((parameter string)) + ``` + +- **blockInternalDataErrors** (used only by Ethereum type coins) + + Errors when fetching internal data from backend. Stored so that the action can be retried. + + ``` + (blockHeight uint32) -> (blockHash [32]byte)+(retryCount byte)+(errorMessage []byte) + ``` + +- **addressAliases** (used only by Ethereum type coins) + + Maps _address_ to address ENS name. + + ``` + (address []byte) -> (ensName []byte) + ``` -The `txid` field as specified in this documentation is a byte array of fixed size with length 32 bytes (*[32]byte*), however some coins may define other fixed size lengths. +**Note:** +The `txid` field as specified in this documentation is a byte array of fixed size with length 32 bytes (_[32]byte_), however some coins may define other fixed size lengths. diff --git a/docs/sync.md b/docs/sync.md new file mode 100644 index 0000000000..e7966a6a14 --- /dev/null +++ b/docs/sync.md @@ -0,0 +1,187 @@ +# Sync + +The sync engine connects blocks from the backend RPC into the local RocksDB index. It is driven by external block notifications (EVM `newHeads` WebSocket, Tron ZeroMQ, BTC ZMQ) and an internal periodic tick. This page documents the loop, the [tip feed](#tip-feed-and-the-stall-watchdog) that drives it, and the knobs that govern how it recovers from transient backend trouble. + +## Sync loop + +```mermaid +%%{init: {"theme": "base", "themeVariables": {"lineColor": "#6b7280", "primaryTextColor": "#111827"}}}%% +flowchart TD + trigger["Notifications or periodic tick"] + debounce["TickAndDebounce"] + loop["syncIndexLoop
retry once after 2.5 s on error"] + resync["ResyncIndex / resyncIndex"] + done["syncNotNeeded
(no work)"] + fork["fork detected
handleFork + DisconnectBlocks"] + mode{"connect mode"} + bulk["BulkConnectBlocks
(large initial range)"] + parallel["ParallelConnectBlocks"] + sequential["connectBlocks + getBlockChain"] + fetch["per-block fetch/retry
(see below)"] + connected["blocks connected"] + recover["errResync / errFork
restart resyncIndex"] + failed["terminal error
returns to syncIndexLoop"] + + trigger --> debounce --> loop --> resync + resync --> done + resync --> fork --> resync + resync --> mode + mode -- "initial sync" --> bulk + mode -- "EVM gap" --> parallel + mode -- "tail" --> sequential + bulk --> fetch + parallel --> fetch + sequential --> fetch + fetch -- OK --> connected + fetch -- "chain changed" --> recover --> resync + fetch -- "terminal error" --> failed --> loop + + classDef normal fill:#e7f0ff,stroke:#4078c0,color:#10243e; + classDef error fill:#ffecec,stroke:#c03535,color:#3b0a0a; + + class trigger,debounce,loop,resync,done,fork,mode,bulk,parallel,sequential,fetch,connected,recover normal; + class failed error; +``` + +The per-block retry loop is shared by `getBlockChain` and `getBlockWorker`. Probe errors are path-specific: `getBlockChain` propagates immediately, while workers retry until three consecutive probe failures. + +```mermaid +%%{init: {"theme": "base", "themeVariables": {"actorBkg": "#e7f0ff", "actorBorder": "#4078c0", "actorTextColor": "#10243e", "activationBkgColor": "#e8f7ed", "activationBorderColor": "#2e8b57", "signalColor": "#6b7280", "signalTextColor": "#111827", "labelBoxBkgColor": "#fff6d7", "labelBoxBorderColor": "#b58400", "loopTextColor": "#312300", "noteBkgColor": "#f1ecff", "noteBorderColor": "#7a55c2"}}}%% +sequenceDiagram + participant Fetch as getBlockChain/getBlockWorker + participant RPC as backend RPC + participant Probe as chain-state probe + participant DB as RocksDB + + Fetch->>RPC: GetBlock(hash, height) + alt OK + RPC-->>Fetch: block + Fetch->>DB: ConnectBlock + else non-retryable error + Fetch-->>Fetch: propagate, except worker mid-queue retries + else retryable error + Fetch-->>Fetch: onRetryableMiss and increment retries + opt threshold reached + Fetch->>Probe: shouldRestartSyncOnMissingBlock + alt restart=true + Probe-->>Fetch: errResync + else restart=false + Probe-->>Fetch: keep retrying + else probe error + Probe-->>Fetch: getBlockChain propagates, worker after 3 failures + end + end + opt MaxStallDuration exceeded + Fetch-->>Fetch: errResync + end + Fetch-->>Fetch: sleep RetryDelay, then retry GetBlock + end +``` + +`errResync` and `errFork` cause `resyncIndex` to be re-entered (handling the new chain state); any other error propagates up and `syncIndexLoop` retries once before waiting for the next trigger. + +## Tip feed and the stall watchdog + +The "Notifications" that wake the loop above are not free-standing — for EVM and Tron they come from a single cached best-header that is advanced **only** by a backend push feed (EVM `newHeads` WebSocket, Tron ZeroMQ). `resyncIndex` reads that cached tip to decide whether work is needed, so a feed that goes quiet freezes the tip and the loop silently concludes `syncNotNeeded`. + +The failure mode that motivated this is a load balancer that drops the upstream **without** signalling `sub.Err()`: the error-driven resubscribe never fires, the cached tip freezes, and the index stalls until the ~15-minute periodic tick — with no error logged and no metric moving. The `tipWatchdog` closes that gap by watching feed liveness directly and healing a silent feed. + +```mermaid +%%{init: {"theme": "base", "themeVariables": {"lineColor": "#6b7280", "primaryTextColor": "#111827"}}}%% +flowchart TD + feed["Backend push feed
EVM newHeads WS · Tron ZeroMQ"] + notifier["feed reader
(newHeads / ZeroMQ)"] + advance["advance cached tip
EVM: setBestHeader(feed header)
Tron: refreshBestHeaderFromChain"] + mark["markSubscriptionAlive
(EVM: only on tip advance)"] + push["PushHandler(NotificationNewBlock)"] + trigger["chanSyncIndex → syncIndexLoop
(sync loop above)"] + ts[("lastSubNotifyNs
liveness timestamp")] + cache[("cached bestHeader
read by resyncIndex → synced?")] + + feed -- notification --> notifier + notifier --> advance + advance -. advances .-> cache + advance -- "tip advanced" --> mark + mark -. stamps .-> ts + advance -- "tip advanced" --> push --> trigger + + subgraph wd ["tipWatchdog (EVM + Tron, started once)"] + tick["tick every
clamp(threshold/3, 5s, 60s)"] + check{"age ≥ TipStaleThreshold?
clamp(30 × blockTime, 30s, 5min)"} + ok["set age gauge · feed alive
return"] + stall["watchdog_stall
feed silent, sub.Err() never fired"] + wpoll["poll tip directly
refreshBestHeaderFromChain"] + wadv["watchdog_tip_advanced"] + wrec["EVM: reconnectRPC
watchdog_reconnect / _failed
Tron: poll-only (ZeroMQ self-heals)"] + end + + ts -. read by .-> check + tick --> check + check -- "no (fresh)" --> ok + check -- "yes (silent)" --> stall --> wpoll + wpoll -. advances .-> cache + wpoll -- "tip advanced" --> wadv --> push + wpoll --> wrec + wrec -. "re-arm window
+ restore push" .-> feed + + classDef normal fill:#e7f0ff,stroke:#4078c0,color:#10243e; + classDef store fill:#e8f7ed,stroke:#2e8b57,color:#0b2c19; + classDef watch fill:#fff6d7,stroke:#b58400,color:#312300; + + class feed,notifier,mark,advance,push,trigger,tick,check,ok normal; + class ts,cache store; + class stall,wpoll,wadv,wrec watch; +``` + +Key invariants this design relies on: + +- **The cached tip is advanced from the feed's own header, not re-derived over the load-balanced call path (EVM).** `newHeads` (WS) is sticky to one upstream, but JSON-RPC calls (`HeaderByNumber`, `GetBlock`) are load-balanced across the pool and can land on a lagging node. Re-querying the tip over that path could read a stale height and silently freeze sync into a false "synced" even while `newHeads` keeps flowing; instead the header delivered by the feed sets the tip directly via `setBestHeader`, which is **monotonic** so a resubscribe onto a slightly-behind node cannot regress the tip and trip a spurious fork. Blocks the call path cannot yet serve surface as ordinary `ErrBlockNotFound` and are absorbed by the [retry budget](#troubleshooting) — visible via `block_not_found_retries` / `sync_yields` — rather than hidden as a frozen tip. (Tron's ZeroMQ notification carries no header, so it still re-queries via `refreshBestHeaderFromChain`.) The monotonic guard only filters *transient* lag, which resolves within seconds; a backend that **genuinely rolls back** to a lower height and stays there delivers only sub-tip heads that the guard keeps rejecting, so the cached tip would otherwise freeze above the backend (equal to the local DB tip) and `resyncIndex` would early-exit as "synced" forever, never reaching its `GetBlockHash(localBestHeight)` fork path. Because rejected heads do not refresh liveness, this case ages the feed past `TipStaleThreshold` like any silent feed, so `tipWatchdog` fires and re-polls the tip **with the monotonic guard lifted** (`refreshBestHeaderFromChain(allowRegress=true)`): a still-lower height after the full stall window is treated as a real rollback, the cached tip follows the backend down (emitting `watchdog_tip_rollback`), and the next `resyncIndex` reaches the fork/disconnect path. A fluke lower poll cannot corrupt state — `resyncIndex` re-confirms via an independent `GetBlockHash`, and a chain that is actually still at the higher tip simply re-advances on the next header. +- **The liveness timestamp is armed when the subscription is established and refreshed only by a feed-driven tip advance.** `markSubscriptionAlive` (EVM) / `markNotifyAlive` (Tron) is stamped on the push-feed path, never by the watchdog's own fallback poll — so a watchdog that is carrying sync can never mask a dead feed: `age` keeps growing until real push delivery resumes. On EVM it is refreshed **only when the delivered header actually moved the tip forward**, so the watchdog also catches a feed that keeps delivering but is stuck on one height (a load-balancer upstream that stopped advancing), not just one that went fully silent. Crucially, EVM also stamps it **once at subscribe time** (`subscribeEvents`): the watchdog's `lastSubNotifyNs == 0` gate uses it as a proxy for "subscription wired up", so if it were stamped *only* on advance, a subscription that comes up silently behind a load balancer (never advancing) would leave the gate closed and the watchdog disabled forever — the cached tip would freeze into a false "synced" with no error or metric. Seeding at subscribe time means even a born-silent feed ages past the threshold and gets polled/reconnected. The watchdog re-arms the window after a *successful* EVM reconnect (and Tron re-arms after each poll to avoid polling every tick during a legitimate lull). +- **`TipStaleThreshold` is chain-aware.** `clamp(30 × averageBlockTimeMs, 30s, 5min)` replaces the old fixed 15 minutes, which on Polygon's 2 s blocks meant ~450 missed blocks before any reaction. Per-chain values: Polygon/Optimism/Base/Avalanche 60 s, BSC/Tron 90 s, Arbitrum 30 s (floor), Ethereum 5 min (cap). The sample interval is `clamp(threshold/3, 5s, 60s)`. +- **Reader goroutines start once.** `newBlockNotifier`, `tipWatchdog`, and the `NewBlock`/`NewTx` channel readers are launched under a `sync.Once`; `reconnectRPC` only re-creates the `EthSubscribe`-bound subscriptions, so a reconnect no longer leaks a fresh reader set. `getBestHeader` no longer does a lock-held passive reconnect — liveness is owned by the watchdog, off the `bestHeaderLock`, so a reconnect can't block concurrent tip readers. + +EVM coverage is inherited by every coin built on `EthereumRPC` (Ethereum, Polygon, BSC, Arbitrum, Optimism, Base, Avalanche); Tron runs the same watchdog poll-only over its ZeroMQ feed. BTC-family coins do not use this cached-tip feed and are unaffected. + +## Troubleshooting + +The retry policy is exposed per chain under `additional_params.missingBlockRetry` in `configs/coins/*.json`. Each field is optional; missing or `<= 0` values fall back to the built-in defaults below. + +| Knob | Current default | Where it bites | Semantic | +| --------------------- | --------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `RetryDelay` | 1 s | `getBlockWorker` (parallel) directly; `getBlockChain` clamps to ≤ 250 ms regardless | Sleep between successive `GetBlock` attempts for the same missing block | +| `RecheckThreshold` | 10 | `getBlockWorker` mid-queue | Retries before calling `shouldRestartSyncOnMissingBlock` | +| `TipRecheckThreshold` | 3 | both loops, at the tail | Retries before chain-state probe, when we're near the tip | +| `MaxStallDuration` | 60 s | both loops | Wall-clock cap before yielding `errResync` | + +Example override (JSON keys are camelCase with the `Ms` suffix for durations): + +```json +"additional_params": { + "missingBlockRetry": { + "retryDelayMs": 1000, + "recheckThreshold": 10, + "tipRecheckThreshold": 3, + "maxStallMs": 60000 + } +} +``` + +When an override is applied, blockbook logs one `sync: missingBlockRetry override applied: …` line at startup so you can confirm the effective values. + +Related Prometheus counters for observing the budget at runtime: + +- `blockbook_index_block_not_found_retries` — every transient `ErrBlockNotFound` observed during sync. +- `blockbook_index_sync_yields{reason="deadline"|"probe_failed"}` — wall-clock cap fired vs chain-state probe failed three times. +- `blockbook_index_reorg_events{type="fork"|"resync"|"disconnect"}` — real reorg signals (not stall yields). + +For the [tip feed](#tip-feed-and-the-stall-watchdog) (EVM and Tron only): + +- `blockbook_backend_subscription_age_seconds` — seconds since the feed last delivered a notification. Healthy: hovers near the chain's block time. A sustained climb to `TipStaleThreshold` (the value `clamp(30 × blockTime, 30s, 5min)` from the watchdog section) means the feed went silent and the watchdog is carrying sync; climbing without bound means the backend is unreachable. +- `blockbook_backend_subscription_events{subscription,event}` — feed lifecycle. `subscription` ∈ `newHeads`, `newPendingTransactions`, `rpc`, `zeromq`; `event` ∈ `watchdog_tick`, `error`, `resubscribed`, `resubscribe_failed`, `watchdog_stall`, `watchdog_tip_advanced`, `watchdog_tip_rollback`, `watchdog_reconnect`, `watchdog_reconnect_failed`. To alert on: `watchdog_tip_advanced` (the fallback poll found blocks the feed had dropped — the push feed is broken), `watchdog_tip_rollback` (the backend's tip dropped below ours and stayed there past the stall window — a real rollback the watchdog regressed the cached tip to so sync could reconcile the fork; EVM only, since Tron's tip is non-monotonic and follows the backend down directly), and a sustained `subscription_age_seconds` at the threshold. +- `blockbook_backend_subscription_events{event="watchdog_tick"}` — incremented once per watchdog evaluation (~every `clamp(TipStaleThreshold/3, 5s, 60s)`). It is the watchdog's heartbeat: the watchdog is the **sole** healer for a silent feed, so if its single goroutine parks (e.g. on a hung reconnect) every other stall signal can lie. `rate(...{event="watchdog_tick"}[2m]) == 0` while the process is up means the watchdog stopped ticking — a parked healer. + +To detect a **stalled sync** (the silent class this watchdog exists for — index not advancing while the process is healthy), alert on: + +- `blockbook_synchronized == 0` sustained (≳ a few minutes) outside initial sync — mirrors `/api/status` `inSync`, so `0` means the index is not keeping up with the tip. This is the single clearest "not OK" signal; it folds in the per-chain freshness/grace logic, so a fast EVM chain at the tip still reads `1`. +- `blockbook_tip_age_seconds > 30 * blockbook_average_block_time_seconds` (guard `blockbook_average_block_time_seconds > 0`) — the observed tip has not advanced in ~30 block intervals. Independent of the watchdog: it climbs whether the feed died, the watchdog parked, or the backend genuinely paused. +- `rate(blockbook_backend_subscription_events{event="watchdog_tick"}[2m]) == 0` (EVM/Tron) — distinguishes a **parked watchdog** from a backend that simply paused: if ticks stopped, the healer itself is dead. diff --git a/docs/testing.md b/docs/testing.md index 76e3113971..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. @@ -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|vertcoin|zcash|zelcash|syscoin)/'"` +* 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 a8885bb7fa..628f7cfe21 100644 --- a/fiat/coingecko.go +++ b/fiat/coingecko.go @@ -1,136 +1,1359 @@ package fiat import ( + "context" "encoding/json" "errors" - "io/ioutil" + "fmt" + "io" + "math/rand" "net/http" + "net/url" + "os" "strconv" + "strings" + "sync" "time" "github.com/golang/glog" - "github.com/syscoin/blockbook/db" + "github.com/linxGnu/grocksdb" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" ) +const ( + DefaultHTTPTimeout = 15 * time.Second + DefaultThrottleDelayMs = 100 + coingeckoHistoryDaysLimit = 365 + coingeckoKeylessRequestsPerMinute = 10 + coingeckoRangeHistorical = "historical" + coingeckoRangeTip = "tip" + coingeckoRangeCapped = "capped" + coingeckoRangeBackfill = "backfill" + // coingeckoTipWindowDays is the daily-series gap (in days) treated as the "tip" range -- + // yesterday's just-closed daily candle. Like every other daily/historical range it is fetched + // from the CDN when one is configured (see sourceURLForRange); only the live current price and + // the high-granularity charts stay on the rate-limited Free/Pro tip endpoint. + coingeckoTipWindowDays = 1 + 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 +) + +// Phase labels for the fiat-rate fetch metrics (fiat_rates_fetched_units_total, +// fiat_rates_fetched_tokens_total, fiat_rates_unable_total). +const ( + fiatPhaseTip = "tip" // daily-series tip, gap == 1 day (yesterday's close); CDN when configured + fiatPhaseBackfill = "backfill" // gap > 1 day or first-seen series, via CDN + fiatPhaseBootstrap = "bootstrap" // full-history population, via CDN + fiatPhaseReconcile = "reconcile" // startup self-healing pass, via CDN +) + +// Reason labels for fiat_rates_unable_total. +const ( + fiatUnableGapTooLarge = "gap_too_large" // series stale >= reconcile guard, probable bug + fiatUnableFetchFailed = "fetch_failed" // HTTP/parse error fetching the range + fiatUnableProviderBan = "provider_banned" // Cloudflare 1015 IP ban + fiatUnableNoBaseTicker = "no_base_ticker" // token day without an existing base-currency ticker + fiatUnableBudgetExhausted = "budget_exhausted" // reconcile wall-clock budget elapsed before completion +) + +// used when retry-after header is missing +var coingeckoThrottleBackoff = time.Minute + +var coingeckoLowPriorityPollInterval = time.Second + +// reqPriority determines which requests reclaim request slots first while a +// shared throttle window (429) is open. +type reqPriority int + +const ( + priorityHigh reqPriority = iota // current tickers + high-granularity fetches + priorityLow // historical (daily) ticker updates +) + +var errCoingeckoHistoricalTokenUpdateInProgress = errors.New("coingecko historical token update already in progress") + // Coingecko is a structure that implements RatesDownloaderInterface type Coingecko struct { - url string - coin string - httpTimeoutSeconds time.Duration - timeFormat string + tipURL string + bootstrapURL string + apiKey string + coin string + platformIdentifier string + platformVsCurrency string + allowedVsCurrencies map[string]struct{} + httpTimeout time.Duration + timeFormat string + httpClient *http.Client + db *db.RocksDB + updatingTokens bool + metrics *common.Metrics + plan string + minHttpRequestInterval time.Duration + bootstrapRequestInterval time.Duration + reqMu sync.Mutex + lastRequestAt time.Time + throttledUntil time.Time + highPriorityInFlight int + cacheMu sync.Mutex + vsCurrencies []string + platformIds []string + platformIdsToTokens map[string]string +} + +// simpleSupportedVSCurrencies https://api.coingecko.com/api/v3/simple/supported_vs_currencies +type simpleSupportedVSCurrencies []string + +type coinsListItem struct { + ID string `json:"id"` + Symbol string `json:"symbol"` + Name string `json:"name"` + Platforms map[string]string `json:"platforms"` +} + +// coinList https://api.coingecko.com/api/v3/coins/list +type coinList []coinsListItem + +type marketPoint [2]float64 +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(url string, coin string, timeFormat string) RatesDownloaderInterface { +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 { + allowedVsCurrenciesMap := getAllowedVsCurrenciesMap(allowedVsCurrencies) + + apiKey := resolveCoinGeckoAPIKey(network, coinShortcut) + normalizedPlan := normalizeCoinGeckoPlan(plan) + + minRequestInterval := time.Duration(0) + bootstrapRequestInterval := time.Duration(0) + if throttleDown { + if normalizedPlan == coingeckoPlanFree { + minRequestInterval = time.Minute / coingeckoKeylessRequestsPerMinute + } else { + minRequestInterval = DefaultThrottleDelayMs * time.Millisecond + } + bootstrapRequestInterval = DefaultThrottleDelayMs * time.Millisecond + } + 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, - coin: coin, - httpTimeoutSeconds: 15 * time.Second, - timeFormat: timeFormat, + tipURL: tipURL, + bootstrapURL: resolvedBootstrapURL, + apiKey: apiKey, + coin: coin, + platformIdentifier: platformIdentifier, + platformVsCurrency: platformVsCurrency, + allowedVsCurrencies: allowedVsCurrenciesMap, + httpTimeout: DefaultHTTPTimeout, + timeFormat: timeFormat, + httpClient: &http.Client{ + Timeout: DefaultHTTPTimeout, + }, + db: db, + minHttpRequestInterval: minRequestInterval, + bootstrapRequestInterval: bootstrapRequestInterval, + 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 +} + +type coingeckoHTTPError struct { + status int + body string + retryAfter time.Duration +} + +func (e *coingeckoHTTPError) Error() string { + return e.body +} + +func parseRetryAfter(value string) time.Duration { + secs, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || secs <= 0 { + return 0 + } + return time.Duration(secs) * time.Second +} + +// retryAfterFromError extracts the server's Retry-After hint from a coingeckoHTTPError, +// if any; zero means no hint and the caller falls back to the fixed backoff. +func retryAfterFromError(err error) time.Duration { + var httpErr *coingeckoHTTPError + if errors.As(err, &httpErr) { + return httpErr.retryAfter + } + return 0 +} + +func throttleBackoffDelay(err error) time.Duration { + delay := retryAfterFromError(err) + if delay <= 0 { + delay = coingeckoThrottleBackoff + } + if delay <= 0 { + return 0 + } + return delay + time.Duration(rand.Int63n(int64(delay/5)+1)) +} + +// coingeckoUserAgent identifies Blockbook to the fiat-rate providers. Go's HTTP client +// otherwise sends the default "Go-http-client/1.1" user-agent, which cdn.trezor.io's +// Cloudflare bot protection rejects with a 403 challenge page: it blocks well-known +// automation user-agents (Go-http-client, curl, python-requests, ...) independently of any +// source-IP allow-list. An explicit Blockbook user-agent clears that filter and identifies +// our traffic to the CDN operators. +func coingeckoUserAgent() string { + version := common.GetVersionInfo().Version + if version == "" { + version = "unknown" } + return "blockbook/" + version } -// makeRequest retrieves the response from Coingecko API at the specified date. -// If timestamp is nil, it fetches the latest market data available. -func (cg *Coingecko) makeRequest(timestamp *time.Time) ([]byte, error) { - requestURL := cg.url + "/coins/" + cg.coin - if timestamp != nil { - requestURL += "/history" +// doReq HTTP client +func doReq(req *http.Request, client *http.Client) ([]byte, error) { + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, &coingeckoHTTPError{ + status: resp.StatusCode, + body: string(body), + retryAfter: parseRetryAfter(resp.Header.Get("Retry-After")), + } + } + return body, nil +} + +func isCoingeckoThrottleError(err error) bool { + if err == nil { + return false + } + var httpErr *coingeckoHTTPError + if errors.As(err, &httpErr) && httpErr.status == http.StatusTooManyRequests { + return true + } + lowerError := strings.ToLower(err.Error()) + // Cloudflare serves rate-limit blocks as a full HTML page that merely contains + // "error code: 1015", so match it as a substring rather than the whole body. + return strings.Contains(lowerError, "error code: 1015") || + strings.Contains(lowerError, "exceeded the rate limit") || + strings.Contains(lowerError, "throttled") +} + +func isCoingeckoCloudflareBanError(err error) bool { + return err != nil && strings.Contains(strings.ToLower(err.Error()), "error code: 1015") +} + +func isCoingeckoHistoricalTokenUpdateInProgressError(err error) bool { + return errors.Is(err, errCoingeckoHistoricalTokenUpdateInProgress) +} + +func (cg *Coingecko) targetsBootstrapCDN(url string) bool { + return cg.bootstrapURL != "" && strings.HasPrefix(url, cg.bootstrapURL) +} + +func (cg *Coingecko) requestIntervalFor(url string) time.Duration { + if cg.targetsBootstrapCDN(url) { + return cg.bootstrapRequestInterval + } + return cg.minHttpRequestInterval +} + +// waitForRequestSlot blocks until this request may fire, honoring the shared throttle window +// and the per-request minimum interval. ctx lets the wait be cut short on shutdown or when the +// reconcile budget expires; on cancellation it returns early and makeReq observes ctx.Err(). +func (cg *Coingecko) waitForRequestSlot(ctx context.Context, priority reqPriority, minInterval time.Duration) { + for { + if ctx.Err() != nil { + return + } + cg.reqMu.Lock() + now := time.Now() + if priority == priorityLow && cg.throttledUntil.After(now) && cg.highPriorityInFlight > 0 { + cg.reqMu.Unlock() + if !sleepCtx(ctx, coingeckoLowPriorityPollInterval) { + return + } + continue + } + slot := now + if !cg.lastRequestAt.IsZero() { + if next := cg.lastRequestAt.Add(minInterval); next.After(slot) { + slot = next + } + } + if cg.throttledUntil.After(slot) { + slot = cg.throttledUntil + } + cg.lastRequestAt = slot + cg.reqMu.Unlock() + if d := time.Until(slot); d > 0 { + if !sleepCtx(ctx, d) { + return + } + } + // Another goroutine's 429 may have extended throttledUntil while we slept on an + // already-reserved slot; firing now would be a guaranteed 429, so re-enter the gate. + cg.reqMu.Lock() + stillThrottled := cg.throttledUntil.After(time.Now()) + cg.reqMu.Unlock() + if !stillThrottled { + return + } } +} + +// sleepCtx sleeps for d unless ctx is cancelled first. It returns true if the full duration +// elapsed, false if ctx was cancelled (so the caller can bail). +func sleepCtx(ctx context.Context, d time.Duration) bool { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return false + case <-t.C: + return true + } +} - req, err := http.NewRequest("GET", requestURL, nil) +// markThrottled opens (or extends) the shared throttle window for all requests. +func (cg *Coingecko) markThrottled(delay time.Duration) { + cg.reqMu.Lock() + if until := time.Now().Add(delay); until.After(cg.throttledUntil) { + cg.throttledUntil = until // extend only, never shorten + } + cg.reqMu.Unlock() +} + +// makeReq HTTP request helper with unbounded retries for throttling errors. ctx bounds the +// otherwise-unbounded retry loop: callers on the steady-state loops pass context.Background() +// (retry forever, as before), while the startup reconcile pass passes a context carrying both +// the shutdown signal and a wall-clock budget, so a persistently throttling endpoint can never +// stall startup and a SIGTERM aborts mid-retry. +func (cg *Coingecko) makeReq(ctx context.Context, url string, endpoint string, plan string, priority reqPriority) ([]byte, error) { + if priority == priorityHigh { + cg.reqMu.Lock() + cg.highPriorityInFlight++ + cg.reqMu.Unlock() + defer func() { + cg.reqMu.Lock() + cg.highPriorityInFlight-- + cg.reqMu.Unlock() + }() + } + minInterval := cg.requestIntervalFor(url) + for attempt := 0; ; attempt++ { + // glog.Infof("Coingecko makeReq %v", url) + // waitForRequestSlot returns immediately if ctx is already done, so a single check after + // it covers both an already-cancelled context and one cancelled while waiting. + cg.waitForRequestSlot(ctx, priority, minInterval) + if err := ctx.Err(); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", coingeckoUserAgent()) + 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 + } + + // A request cut short by our context (shutdown or the reconcile budget) is expected, not + // a provider failure: return quietly without an error log or the "error" status metric. + // A client-side HTTP timeout is a real failure and deliberately falls through below. + if ctx.Err() != nil { + glog.V(1).Infof("Coingecko makeReq %v aborted: %v", url, err) + return nil, err + } + + if isCoingeckoCloudflareBanError(err) { + if cg.metrics != nil { + cg.metrics.CoingeckoRequests.With(common.Labels{"endpoint": endpoint, "status": "banned"}).Inc() + } + glog.Warningf("Coingecko makeReq %v Cloudflare rate-limit ban (1015), fast-failing: %v", url, err) + return nil, err + } + 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 cg.metrics != nil { + cg.metrics.CoingeckoRequests.With(common.Labels{"endpoint": endpoint, "status": "throttle"}).Inc() + } + + delay := throttleBackoffDelay(err) + cg.markThrottled(delay) + glog.Warningf("Coingecko makeReq %v throttled on attempt %d, retrying in %v: %v", url, attempt+1, delay, err) + } +} + +// SimpleSupportedVSCurrencies /simple/supported_vs_currencies +func (cg *Coingecko) simpleSupportedVSCurrenciesAt(ctx context.Context, baseURL string, priority reqPriority) (simpleSupportedVSCurrencies, error) { + url := baseURL + "/simple/supported_vs_currencies" + resp, err := cg.makeReq(ctx, url, "supported_vs_currencies", cg.plan, priority) + if err != nil { + return nil, err + } + var data simpleSupportedVSCurrencies + err = json.Unmarshal(resp, &data) if err != nil { - glog.Errorf("Error creating a new request for %v: %v", requestURL, err) return nil, err } - req.Close = true - req.Header.Set("Content-Type", "application/json") + if len(cg.allowedVsCurrencies) == 0 { + return data, nil + } + filtered := make([]string, 0, len(cg.allowedVsCurrencies)) + for _, c := range data { + if _, found := cg.allowedVsCurrencies[c]; found { + filtered = append(filtered, c) + } + } + return filtered, nil +} - // Add query parameters - q := req.URL.Query() +// SimplePrice /simple/price Multiple ID and Currency (ids, vs_currencies) +func (cg *Coingecko) simplePrice(ctx context.Context, ids []string, vsCurrencies []string) (*map[string]map[string]float32, error) { + params := url.Values{} + idsParam := strings.Join(ids, ",") + vsCurrenciesParam := strings.Join(vsCurrencies, ",") - // Add a unix timestamp to query parameters to get uncached responses - currentTimestamp := strconv.FormatInt(time.Now().UTC().UnixNano(), 10) - q.Add("current_timestamp", currentTimestamp) + params.Add("ids", idsParam) + params.Add("vs_currencies", vsCurrenciesParam) - if timestamp == nil { - q.Add("market_data", "true") - q.Add("localization", "false") - q.Add("tickers", "false") - q.Add("community_data", "false") - q.Add("developer_data", "false") - } else { - timestampFormatted := timestamp.Format(cg.timeFormat) - q.Add("date", timestampFormatted) + url := fmt.Sprintf("%s/simple/price?%s", cg.tipURL, params.Encode()) + // only called from CurrentTickers, therefore always high priority + resp, err := cg.makeReq(ctx, url, "simple/price", cg.plan, priorityHigh) + if err != nil { + return nil, err } - req.URL.RawQuery = q.Encode() - client := &http.Client{ - Timeout: cg.httpTimeoutSeconds, + t := make(map[string]map[string]float32) + err = json.Unmarshal(resp, &t) + if err != nil { + return nil, err } - resp, err := client.Do(req) + + return &t, nil +} + +// CoinsList /coins/list +func (cg *Coingecko) coinsListAt(ctx context.Context, baseURL string, priority reqPriority) (coinList, error) { + params := url.Values{} + platform := "false" + if cg.platformIdentifier != "" { + platform = "true" + } + params.Add("include_platform", platform) + url := fmt.Sprintf("%s/coins/list?%s", baseURL, params.Encode()) + resp, err := cg.makeReq(ctx, url, "coins/list", cg.plan, priority) if err != nil { return nil, err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, errors.New("Invalid response status: " + string(resp.Status)) + + var data coinList + err = json.Unmarshal(resp, &data) + if err != nil { + return nil, err + } + return data, nil +} + +// coinMarketChart /coins/{id}/market_chart?vs_currency={usd, eur, jpy, etc.}&days={1,14,30,max} +func (cg *Coingecko) coinMarketChartAt(ctx context.Context, baseURL string, id string, vs_currency string, days string, daily bool, priority reqPriority) (*marketChartPrices, error) { + if len(id) == 0 || len(vs_currency) == 0 || len(days) == 0 { + return nil, fmt.Errorf("id, vs_currency, and days is required") } - bodyBytes, err := ioutil.ReadAll(resp.Body) + + params := url.Values{} + 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", baseURL, id, params.Encode()) + resp, err := cg.makeReq(ctx, url, "market_chart", cg.plan, priority) if err != nil { return nil, err } - return bodyBytes, nil + + m := marketChartPrices{} + err = json.Unmarshal(resp, &m) + if err != nil { + return &m, err + } + + return &m, nil } -// GetData gets fiat rates from API at the specified date and returns a CurrencyRatesTicker -// If timestamp is nil, it will download the current fiat rates. -func (cg *Coingecko) getTicker(timestamp *time.Time) (*db.CurrencyRatesTicker, error) { - dataTimestamp := timestamp - if timestamp == nil { - timeNow := time.Now() - dataTimestamp = &timeNow +func (cg *Coingecko) platformIdsAt(ctx context.Context, baseURL string, priority reqPriority) error { + if cg.platformIdentifier == "" { + return nil } - dataTimestampUTC := dataTimestamp.UTC() - ticker := &db.CurrencyRatesTicker{Timestamp: &dataTimestampUTC} - bodyBytes, err := cg.makeRequest(timestamp) + cl, err := cg.coinsListAt(ctx, baseURL, priority) if err != nil { + return err + } + idsMap := make(map[string]string, 64) + ids := make([]string, 0, 64) + for i := range cl { + id, found := cl[i].Platforms[cg.platformIdentifier] + if found && id != "" { + idsMap[cl[i].ID] = id + ids = append(ids, cl[i].ID) + } + } + cg.cacheMu.Lock() + cg.platformIds = ids + cg.platformIdsToTokens = idsMap + cg.cacheMu.Unlock() + return nil +} + +// CurrentTickers returns the latest exchange rates +func (cg *Coingecko) CurrentTickers() (*common.CurrencyRatesTicker, error) { + var newTickers = common.CurrencyRatesTicker{} + + cg.cacheMu.Lock() + vsCurrencies := cg.vsCurrencies + cg.cacheMu.Unlock() + if vsCurrencies == nil { + vs, err := cg.simpleSupportedVSCurrenciesAt(context.Background(), cg.tipURL, priorityHigh) + if err != nil { + return nil, err + } + vsCurrencies = vs + cg.cacheMu.Lock() + cg.vsCurrencies = vs + cg.cacheMu.Unlock() + } + prices, err := cg.simplePrice(context.Background(), []string{cg.coin}, vsCurrencies) + if err != nil || prices == nil { return nil, err } + newTickers.Rates = make(map[string]float32, len((*prices)[cg.coin])) + for t, v := range (*prices)[cg.coin] { + newTickers.Rates[t] = v + } - type FiatRatesResponse struct { - MarketData struct { - Prices map[string]float64 `json:"current_price"` - } `json:"market_data"` + if cg.platformIdentifier != "" && cg.platformVsCurrency != "" { + cg.cacheMu.Lock() + platformIds, platformIdsToTokens := cg.platformIds, cg.platformIdsToTokens + cg.cacheMu.Unlock() + if platformIdsToTokens == nil { + err = cg.platformIdsAt(context.Background(), cg.tipURL, priorityHigh) + if err != nil { + return nil, err + } + cg.cacheMu.Lock() + platformIds, platformIdsToTokens = cg.platformIds, cg.platformIdsToTokens + cg.cacheMu.Unlock() + } + newTickers.TokenRates = make(map[string]float32) + from := 0 + const maxRequestLen = 6000 + requestLen := 0 + for to := 0; to < len(platformIds); to++ { + requestLen += len(platformIds[to]) + 3 // 3 characters for the comma separator %2C + if requestLen > maxRequestLen || to+1 >= len(platformIds) { + tokenPrices, err := cg.simplePrice(context.Background(), platformIds[from:to+1], []string{cg.platformVsCurrency}) + if err != nil || tokenPrices == nil { + return nil, err + } + for id, v := range *tokenPrices { + t, found := platformIdsToTokens[id] + if found { + newTickers.TokenRates[t] = v[cg.platformVsCurrency] + } + } + from = to + 1 + requestLen = 0 + } + } } + newTickers.Timestamp = time.Now().UTC() + return &newTickers, nil +} - var data FiatRatesResponse - err = json.Unmarshal(bodyBytes, &data) +func (cg *Coingecko) getHighGranularityTickers(days string) (*[]common.CurrencyRatesTicker, error) { + mc, err := cg.coinMarketChartAt(context.Background(), cg.tipURL, cg.coin, highGranularityVsCurrency, days, false, priorityHigh) if err != nil { - glog.Errorf("Error parsing FiatRates response: %v", err) return nil, err } - ticker.Rates = data.MarketData.Prices - return ticker, nil + 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 + } + 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) +} + +// metadataURL returns the base URL for low-volume metadata calls +// (supported_vs_currencies, coins/list). These are cheap and stable, so prefer the CDN +// (no rate limit) whenever it is configured, keeping the Free tier reserved for the tip. +func (cg *Coingecko) metadataURL() string { + if cg.bootstrapURL != "" { + return cg.bootstrapURL + } + return cg.tipURL } -// MarketDataExists checks if there's data available for the specific timestamp. -func (cg *Coingecko) marketDataExists(timestamp *time.Time) (bool, error) { - resp, err := cg.makeRequest(timestamp) +// sourceURLForRange routes daily/historical fetches to the right backend. Every daily range -- +// including the 1-day "tip" range (yesterday's just-closed daily candle) -- goes to the CDN when +// a CDN/bootstrap URL is configured. This matters most for the per-token daily tip, which is +// thousands of requests for platforms like Ethereum and must not run against the rate-limited +// Free tier. Only genuinely live, low-volume calls stay on cg.tipURL -- the current price via +// /simple/price and the high-granularity hourly/5-minute charts -- and those do not go through +// here. When no CDN URL is configured we fall back to the tip endpoint to preserve pre-CDN +// behavior. The rangeKind is retained for call-site clarity (and to keep per-range routing easy +// to reintroduce); routing is currently uniform across all daily ranges. +func (cg *Coingecko) sourceURLForRange(rangeKind string) string { + if cg.bootstrapURL != "" { + return cg.bootstrapURL + } + return cg.tipURL +} + +// phaseForRange maps a resolved range kind to the metric phase label. +func phaseForRange(rangeKind string) string { + switch rangeKind { + case coingeckoRangeTip: + return fiatPhaseTip + case coingeckoRangeHistorical: + return fiatPhaseBootstrap + default: // backfill + capped are both high-volume CDN ranges + return fiatPhaseBackfill + } +} + +func (cg *Coingecko) resolveHistoricalDays(lastTicker *common.CurrencyRatesTicker, allowMax bool) (string, bool, string) { + if lastTicker == nil { + if allowMax { + // Bootstrap mode only: for the very first full historical population use full range. + return "max", true, coingeckoRangeHistorical + } + // 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 <= coingeckoTipWindowDays { + // The daily-series tip: a single missing day (yesterday's close). Routed to the CDN like + // every other daily range when one is configured (see sourceURLForRange). + return strconv.Itoa(d), true, coingeckoRangeTip + } + 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). We intentionally cap backfill to 365 days. + return strconv.Itoa(coingeckoHistoryDaysLimit), true, coingeckoRangeCapped + } + // A multi-day gap (downtime catch-up): high-volume, routed to the CDN. + return strconv.Itoa(d), true, coingeckoRangeBackfill +} + +// fillFromMarketChart writes the whole-day points from a market_chart response into +// tickersToUpdate. With fillMissingOnly set, an already-present rate for the series is left +// intact (used by the startup reconciliation so it repairs holes without rewriting existing +// data). When targetDays is non-nil, only points whose day-bucket is in the set are +// considered, so the reconciliation touches (and therefore persists) only the missing days. +// Returns the number of daily points written and the number of token points skipped because +// their day had no base-currency ticker yet. +func (cg *Coingecko) fillFromMarketChart(tickersToUpdate map[uint]*common.CurrencyRatesTicker, mc *marketChartPrices, vsCurrency string, token string, fillMissingOnly bool, targetDays map[uint]struct{}) (written int, noBaseTicker int, err error) { + warningLogged := false + for _, p := range mc.Prices { + timestamp := uint(p[0]) + if timestamp > 100000000000 { + // convert timestamp from milliseconds to seconds + timestamp /= 1000 + } + rate := float32(p[1]) + if timestamp%(24*3600) != 0 || timestamp == 0 || rate == 0 { + // process only tickers for the whole day with non 0 value + continue + } + if targetDays != nil { + if _, wanted := targetDays[timestamp]; !wanted { + continue + } + } + ticker, found := tickersToUpdate[timestamp] + if !found { + u := time.Unix(int64(timestamp), 0).UTC() + ticker, err = cg.db.FiatRatesGetTicker(&u) + if err != nil { + return written, noBaseTicker, err + } + if ticker == nil { + if token != "" { // if the base currency is not found in DB, do not create ticker for the token + noBaseTicker++ + if !warningLogged { + glog.Warningf("No base currency ticker for date %v for token %s", u, token) + warningLogged = true + } + continue + } + ticker = &common.CurrencyRatesTicker{ + Timestamp: u, + Rates: make(map[string]float32), + } + } + tickersToUpdate[timestamp] = ticker + } + if token == "" { + if fillMissingOnly { + if _, ok := ticker.Rates[vsCurrency]; ok { + continue + } + } + ticker.Rates[vsCurrency] = rate + } else { + if ticker.TokenRates == nil { + ticker.TokenRates = make(map[string]float32) + } + if fillMissingOnly { + if _, ok := ticker.TokenRates[token]; ok { + continue + } + } + ticker.TokenRates[token] = rate + } + written++ + } + return written, noBaseTicker, nil +} + +func (cg *Coingecko) getHistoricalTicker(ctx context.Context, 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 { - glog.Error("Error getting market data: ", err) return false, err } - type FiatRatesResponse struct { - MarketData struct { - Prices map[string]interface{} `json:"current_price"` - } `json:"market_data"` + days, shouldRequest, rangeKind := cg.resolveHistoricalDays(lastTicker, allowMax) + if !shouldRequest { + return false, nil } - var data FiatRatesResponse - err = json.Unmarshal(resp, &data) + if cg.metrics != nil { + cg.metrics.CoingeckoRangeRequests.With(common.Labels{"range": rangeKind}).Inc() + } + // all daily/historical ranges (including the 1-day tip) use the CDN when configured; only the + // live current price and high-granularity charts stay on the rate-limited tip endpoint + baseURL := cg.sourceURLForRange(rangeKind) + phase := phaseForRange(rangeKind) + // both callers update historical (daily) tickers, therefore always low priority + mc, err := cg.coinMarketChartAt(ctx, baseURL, coinId, vsCurrency, days, true, priorityLow) if err != nil { - glog.Errorf("Error parsing Coingecko response: %v", err) return false, err } - return len(data.MarketData.Prices) != 0, nil + written, noBaseTicker, err := cg.fillFromMarketChart(tickersToUpdate, mc, vsCurrency, token, false, nil) + if err != nil { + return false, err + } + cg.observeFetchedUnits(phase, written) + if token != "" && written > 0 { + cg.observeFetchedToken(phase, 1) + } + cg.observeUnable(phase, fiatUnableNoBaseTicker, noBaseTicker) + return true, nil +} + +func (cg *Coingecko) observeFetchedUnits(phase string, n int) { + if cg.metrics != nil && n > 0 { + cg.metrics.FiatRatesFetchedUnits.With(common.Labels{"phase": phase}).Add(float64(n)) + } +} + +func (cg *Coingecko) observeFetchedToken(phase string, n int) { + if cg.metrics != nil && n > 0 { + cg.metrics.FiatRatesFetchedTokens.With(common.Labels{"phase": phase}).Add(float64(n)) + } +} + +func (cg *Coingecko) observeUnable(phase string, reason string, n int) { + if cg.metrics != nil && n > 0 { + cg.metrics.FiatRatesUnable.With(common.Labels{"phase": phase, "reason": reason}).Add(float64(n)) + } +} + +func (cg *Coingecko) storeTickers(tickersToUpdate map[uint]*common.CurrencyRatesTicker) error { + if len(tickersToUpdate) > 0 { + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + for _, v := range tickersToUpdate { + if err := cg.db.FiatRatesStoreTicker(wb, v); err != nil { + return err + } + } + if err := cg.db.WriteBatch(wb); err != nil { + return err + } + } + return nil +} + +// 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 + } + metadataURL := cg.metadataURL() + if bootstrapInProgress { + if !cg.canUseBootstrapMax() { + return coingeckoBootstrapPreconditionError() + } + allowMax = true + } + + // reload vs_currencies + vs, err := cg.simpleSupportedVSCurrenciesAt(context.Background(), metadataURL, priorityLow) + if err != nil { + return err + } + cg.cacheMu.Lock() + cg.vsCurrencies = vs + cg.cacheMu.Unlock() + + hadFailures := false + var banErr error + for _, currency := range vs { + // get historical rates for each currency + var err error + if _, err = cg.getHistoricalTicker(context.Background(), tickersToUpdate, cg.coin, currency, "", allowMax); err != nil { + hadFailures = true + // 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) + if isCoingeckoCloudflareBanError(err) { + banErr = err + break + } + } + } + if err := cg.storeTickers(tickersToUpdate); err != nil { + return err + } + if banErr != nil { + return banErr + } + 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 { + cg.reqMu.Lock() + if cg.updatingTokens { + cg.reqMu.Unlock() + return errCoingeckoHistoricalTokenUpdateInProgress + } + cg.updatingTokens = true + cg.reqMu.Unlock() + defer func() { + cg.reqMu.Lock() + cg.updatingTokens = false + cg.reqMu.Unlock() + }() + tickersToUpdate := make(map[uint]*common.CurrencyRatesTicker) + + if cg.platformIdentifier != "" && cg.platformVsCurrency != "" { + allowMax := false + bootstrapInProgress, _, err := historicalBootstrapInProgress(cg.db) + if err != nil { + return err + } + metadataURL := cg.metadataURL() + if bootstrapInProgress { + if !cg.canUseBootstrapMax() { + return coingeckoBootstrapPreconditionError() + } + allowMax = true + } + + // reload platform ids + if err := cg.platformIdsAt(context.Background(), metadataURL, priorityLow); err != nil { + return err + } + cg.cacheMu.Lock() + platformIds, platformIdsToTokens := cg.platformIds, cg.platformIdsToTokens + cg.cacheMu.Unlock() + glog.Infof("Coingecko returned %d %s tokens ", len(platformIds), cg.coin) + count := 0 + failed := 0 + var banErr error + // get token historical rates + for tokenId, token := range platformIdsToTokens { + var err error + if _, err = cg.getHistoricalTicker(context.Background(), tickersToUpdate, tokenId, cg.platformVsCurrency, token, allowMax); err != nil { + failed++ + // 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) + if isCoingeckoCloudflareBanError(err) { + banErr = err + break + } + } + count++ + if count%100 == 0 { + if err := cg.storeTickers(tickersToUpdate); err != nil { + return err + } + tickersToUpdate = make(map[uint]*common.CurrencyRatesTicker) + glog.Infof("Coingecko updated %d of %d token tickers", count, len(platformIds)) + } + } + // Persist the final (sub-100) batch, then log a clear completion line so the progress + // sequence ends on the full count instead of stopping at the last multiple of 100. + if err := cg.storeTickers(tickersToUpdate); err != nil { + return err + } + if banErr != nil { + glog.Warningf("Coingecko stopped updating token tickers after %d of %d (provider ban), %d failed", count, len(platformIds), failed) + return banErr + } + glog.Infof("Coingecko finished updating %d of %d token tickers (%d failed)", count, len(platformIds), failed) + return nil + } + + return nil +} + +// reconcileTarget identifies one fiat-rate series to reconcile: a base-coin vsCurrency +// (token == "") or a token priced in platformVsCurrency. +type reconcileTarget struct { + coinId string + vsCurrency string + token string +} + +// missingDayWindow scans the stored daily tickers and determines which whole days are missing +// within the reconcile window. It returns the missing day-bucket timestamps (unix seconds), +// the earliest missing day, and whether any history exists at all. "Missing" means no record +// for that day between the oldest in-window record and the last reconcilable day (the tip and +// the still-forming current day are excluded — they belong to the runtime downloader). When +// the window holds no records but history exists, the whole window counts as missing (a +// trailing gap larger than the window), which the caller treats as a probable bug. +func (cg *Coingecko) missingDayWindow(windowDays int) (missing map[uint]struct{}, earliest int64, haveHistory bool, err error) { + now := time.Now().UTC().Unix() + today := now - now%secondsInDay + windowStart := today - int64(windowDays)*secondsInDay + // reconciliation owns days strictly older than the tip; the tip (gap <= 1 day) and the + // still-forming current day are the runtime downloader's responsibility. + lastReconcilable := today - int64(coingeckoTipWindowDays+1)*secondsInDay + + timestamps, err := cg.db.FiatRatesGetTickerTimestamps(time.Unix(windowStart, 0).UTC()) + if err != nil { + return nil, 0, false, err + } + present := make(map[int64]struct{}, len(timestamps)) + firstPresent := int64(0) + for _, ts := range timestamps { + day := ts - ts%secondsInDay + if day > lastReconcilable { + continue + } + if _, ok := present[day]; !ok { + present[day] = struct{}{} + if firstPresent == 0 || day < firstPresent { + firstPresent = day + } + } + } + + startDay := windowStart + if len(present) > 0 { + // don't expect data older than the oldest record we have (the series/coin may simply + // not go back that far); only fill gaps from there forward. + startDay = firstPresent + } else { + lastAny, err := cg.db.FiatRatesFindLastTicker("", "") + if err != nil { + return nil, 0, false, err + } + if lastAny == nil { + return nil, 0, false, nil // no history at all, nothing to reconcile + } + lastDay := lastAny.Timestamp.UTC().Unix() + lastDay -= lastDay % secondsInDay + if lastDay >= windowStart { + // recent data exists but nothing old enough to reconcile (young DB, or only + // tip/current-day records) -> not a gap, nothing to do. + return make(map[uint]struct{}), 0, true, nil + } + // history predates the window entirely -> a trailing gap larger than the window; + // startDay stays at windowStart so the whole window is counted missing (-> flagged). + } + + missing = make(map[uint]struct{}) + earliest = 0 + for day := startDay; day <= lastReconcilable; day += secondsInDay { + if _, ok := present[day]; ok { + continue + } + if earliest == 0 { + earliest = day + } + missing[uint(day)] = struct{}{} + } + return missing, earliest, true, nil +} + +// ReconcileHistoricalRates is the blocking startup self-healing pass. It repairs daily fiat +// rates missing from the DB — both interior holes and trailing gaps — within the last +// windowDays days, for the base coin (every vsCurrency) and every token, filling only the +// missing days so existing data is never rewritten. All fetching goes to the CDN. It returns +// the number of daily points filled (0 when the DB is already healthy). +// +// Two stages, with the gap guard acting as a safety valve against runaway repair: +// - Stage 1 reads the DB (keys only) and computes which whole days are missing. If 90+ days +// (maxGapDays) are missing it is reported as gap_too_large and nothing is fetched, since +// such a gap signals a bug/misconfiguration rather than routine catch-up. If zero days are +// missing it returns immediately — the common, healthy case makes no network requests. +// - Stage 2 fetches just the missing-day range from the CDN for each series and fills only +// those days. +// +// It is a no-op while bootstrap is still in progress (the bootstrap pass owns full-history +// population) or when no CDN URL is configured. +// +// ctx bounds the whole pass: it carries both the shutdown signal and a wall-clock budget, so a +// SIGTERM or a persistently throttling CDN aborts the pass promptly instead of stalling startup. +// Persistence is all-or-nothing per pass: results are written only after the loop has run to +// completion over every target. On any early exit (abort, budget timeout, Cloudflare ban, or DB +// error) the partially fetched days are discarded, not persisted, so a day is never marked +// "present" while still missing the series the un-run targets would have filled (which the +// whole-day key-only stage-1 scan could not later detect); the next startup re-reconciles the +// gap. A budget timeout is recorded as fiat_rates_unable_total{reason="budget_exhausted"} so it +// is visible in monitoring. (An isolated per-series fetch failure is skipped, not an early exit: +// the pass still completes and persists, leaving that series' day a partial-day hole.) +// +// Scope: this repairs days that are wholly absent from the DB. A day that has a record but is +// missing a particular vsCurrency or token (a partial-day hole) is treated as present and is not +// repaired here; that is handled separately. +func (cg *Coingecko) ReconcileHistoricalRates(ctx context.Context, windowDays int, maxGapDays int) (int, error) { + bootstrapInProgress, _, err := historicalBootstrapInProgress(cg.db) + if err != nil { + return 0, err + } + if bootstrapInProgress { + glog.Info("FiatRates reconcile: bootstrap still in progress, skipping startup reconciliation") + return 0, nil + } + if cg.bootstrapURL == "" { + glog.Info("FiatRates reconcile: no CDN bootstrap URL configured, skipping startup reconciliation") + return 0, nil + } + + // ---- Stage 1: read DB (cheap key scan), find which whole days are missing ---- + missingDays, earliestMissing, haveHistory, err := cg.missingDayWindow(windowDays) + if err != nil { + return 0, fmt.Errorf("reconcile: scan missing days: %w", err) + } + if !haveHistory { + glog.Info("FiatRates reconcile: no historical rates present, nothing to reconcile") + return 0, nil + } + if len(missingDays) == 0 { + glog.Infof("FiatRates reconcile: no missing days within the last %d days, historical rates are healthy", windowDays) + return 0, nil + } + if len(missingDays) >= maxGapDays { + // a months-long gap is almost certainly a bug/misconfiguration; surface it instead of + // firing a multi-thousand-request backfill on every restart. + cg.observeUnable(fiatPhaseReconcile, fiatUnableGapTooLarge, len(missingDays)) + glog.Warningf("FiatRates reconcile: %d missing days within the %d-day window (>= %d guard), probable bug/misconfiguration; not auto-backfilling", len(missingDays), windowDays, maxGapDays) + return 0, nil + } + + // fetch a range that spans back to the earliest missing day (capped to the window) + nowDay := time.Now().UTC().Unix() + nowDay -= nowDay % secondsInDay + fetchDays := int((nowDay-earliestMissing)/secondsInDay) + 1 + if fetchDays > windowDays { + fetchDays = windowDays + } + days := strconv.Itoa(fetchDays) + cdnURL := cg.sourceURLForRange(coingeckoRangeBackfill) + metaURL := cg.metadataURL() + + // Shutdown/budget may already be signaled during the cheap stage-1 scan; bail before any + // network call. + if err := ctx.Err(); err != nil { + glog.Warningf("FiatRates reconcile: aborted before backfill (%v), skipping; gap will be reconciled on next startup", err) + return 0, nil + } + + // enumerate the series to repair: base coin x each vsCurrency, plus each token + vs, err := cg.simpleSupportedVSCurrenciesAt(ctx, metaURL, priorityLow) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + glog.Warningf("FiatRates reconcile: aborted fetching supported_vs_currencies (%v); gap will be reconciled on next startup", ctxErr) + return 0, nil + } + return 0, fmt.Errorf("reconcile: supported_vs_currencies: %w", err) + } + cg.cacheMu.Lock() + cg.vsCurrencies = vs + cg.cacheMu.Unlock() + + targets := make([]reconcileTarget, 0, len(vs)) + for _, currency := range vs { + targets = append(targets, reconcileTarget{coinId: cg.coin, vsCurrency: currency}) + } + if cg.platformIdentifier != "" && cg.platformVsCurrency != "" { + if err := cg.platformIdsAt(ctx, metaURL, priorityLow); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + glog.Warningf("FiatRates reconcile: aborted fetching coins/list (%v); gap will be reconciled on next startup", ctxErr) + return 0, nil + } + return 0, fmt.Errorf("reconcile: coins/list: %w", err) + } + cg.cacheMu.Lock() + platformIdsToTokens := cg.platformIdsToTokens + cg.cacheMu.Unlock() + for tokenId, token := range platformIdsToTokens { + targets = append(targets, reconcileTarget{coinId: tokenId, vsCurrency: cg.platformVsCurrency, token: token}) + } + } + + glog.Infof("FiatRates reconcile stage 1: %d missing day(s) within %dd (earliest %s); fetching days=%d for %d series", + len(missingDays), windowDays, time.Unix(earliestMissing, 0).UTC().Format("2006-01-02"), fetchDays, len(targets)) + + // ---- Stage 2: fetch the missing-day range from the CDN and fill only those days ---- + // targetDays ensures only the (few) missing-day records are touched, so the shared map holds at + // most len(missingDays) records regardless of how many series we scan. + // + // All-or-nothing persistence: every target writes into the SAME per-day records (base coin x + // each vsCurrency first, then every token), so a day record is only as complete as it will ever + // be once ALL targets have run. We therefore persist the map only after a clean, complete pass. + // On any early exit -- a shutdown/budget abort, a Cloudflare ban, or a DB fill error -- the + // accumulated records are missing the series from the un-run targets (e.g. after a base-phase + // abort they hold base rates but no token rates). Because stage-1 detection is whole-day + // key-only, persisting such a record would mark the day "present" and it would never be + // reconciled again, silently dropping the un-run series for good. So on an early exit we discard + // the partial map and let the next startup re-reconcile the whole gap from scratch. (A future + // per-day completion checkpoint could let an aborted run resume without refetching.) + tickersToUpdate := make(map[uint]*common.CurrencyRatesTicker) + filled, failed, repairedTokens, noBaseTotal := 0, 0, 0, 0 + var banErr, abortErr error + abortAt := 0 + for i, c := range targets { + // Abort promptly on shutdown or budget timeout. The check before the fetch catches + // cancellation between iterations; the one after catches cancellation during the in-flight + // fetch. The accumulated map is discarded after the loop (see above). + if err := ctx.Err(); err != nil { + abortErr, abortAt = err, i + break + } + mc, err := cg.coinMarketChartAt(ctx, cdnURL, c.coinId, c.vsCurrency, days, true, priorityLow) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + abortErr, abortAt = ctxErr, i + break + } + failed++ + if isCoingeckoCloudflareBanError(err) { + cg.observeUnable(fiatPhaseReconcile, fiatUnableProviderBan, 1) + banErr, abortAt = err, i + glog.Errorf("FiatRates reconcile: Cloudflare ban fetching %s/%s, stopping: %v", c.coinId, c.vsCurrency, err) + break + } + cg.observeUnable(fiatPhaseReconcile, fiatUnableFetchFailed, 1) + glog.Errorf("FiatRates reconcile: fetch %s/%s failed: %v", c.coinId, c.vsCurrency, err) + continue + } + written, noBaseTicker, err := cg.fillFromMarketChart(tickersToUpdate, mc, c.vsCurrency, c.token, true, missingDays) + if err != nil { + // A DB fill error is an abnormal mid-pass exit: discard the partial map (do not persist) + // and surface the error so the next startup retries the whole gap. + return 0, fmt.Errorf("reconcile: fill %s/%s: %w", c.coinId, c.vsCurrency, err) + } + filled += written + noBaseTotal += noBaseTicker + if c.token != "" && written > 0 { + repairedTokens++ + } + } + + // Early exit: discard the incomplete map (see the block comment above) so no day is wrongly + // marked present; nothing is persisted, so report 0 filled. + if abortErr != nil { + remaining := len(targets) - abortAt + // A budget timeout (vs. a clean shutdown) is worth surfacing in monitoring: the window was + // too small or the CDN too slow to finish within the budget. + if errors.Is(abortErr, context.DeadlineExceeded) { + cg.observeUnable(fiatPhaseReconcile, fiatUnableBudgetExhausted, remaining) + } + glog.Warningf("FiatRates reconcile stage 2: aborted (%v) after %d/%d series, %d fetch failures; discarding partial results, gap will be reconciled on next startup", abortErr, abortAt, len(targets), failed) + return 0, nil + } + if banErr != nil { + glog.Warningf("FiatRates reconcile stage 2: Cloudflare ban after %d/%d series, %d fetch failures; discarding partial results, gap will be reconciled on next startup", abortAt, len(targets), failed) + return 0, banErr + } + + // Clean, complete pass: persist, then record what was actually fetched and persisted. + if err := cg.storeTickers(tickersToUpdate); err != nil { + return 0, err + } + cg.observeFetchedUnits(fiatPhaseReconcile, filled) + cg.observeFetchedToken(fiatPhaseReconcile, repairedTokens) + cg.observeUnable(fiatPhaseReconcile, fiatUnableNoBaseTicker, noBaseTotal) + glog.Infof("FiatRates reconcile stage 2: filled %d point(s) across %d series (%d token series), %d fetch failures", filled, len(targets), repairedTokens, failed) + return filled, nil } diff --git a/fiat/coingecko_test.go b/fiat/coingecko_test.go new file mode 100644 index 0000000000..e68fa7dc18 --- /dev/null +++ b/fiat/coingecko_test.go @@ -0,0 +1,1782 @@ +//go:build unittest + +package fiat + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/linxGnu/grocksdb" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" +) + +// midnightDaysAgo returns the UTC midnight (a whole-day bucket) n days before today. +func midnightDaysAgo(n int) time.Time { + s := time.Now().UTC().Unix() + s -= s % secondsInDay + s -= int64(n) * secondsInDay + return time.Unix(s, 0).UTC() +} + +// seedDailyTicker stores a single daily ticker directly into the DB for test setup. +func seedDailyTicker(t *testing.T, d *db.RocksDB, ts time.Time, rates map[string]float32, tokenRates map[string]float32) { + t.Helper() + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + ticker := &common.CurrencyRatesTicker{Timestamp: ts, Rates: rates, TokenRates: tokenRates} + if err := d.FiatRatesStoreTicker(wb, ticker); err != nil { + t.Fatalf("FiatRatesStoreTicker failed: %v", err) + } + if err := d.WriteBatch(wb); err != nil { + t.Fatalf("WriteBatch failed: %v", err) + } +} + +// roundTripFunc serves canned HTTP responses without binding a loopback port +// (httptest needs a local listener, which some sandboxes disallow) and lets a +// test assert the exact number of attempts deterministically. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func stubHTTPResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +} + +const cloudflareBanBody = "The owner of this website has banned you temporarily. error code: 1015" + +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("one day gap is the tip", func(t *testing.T) { + cg := Coingecko{} + days, shouldRequest, rangeKind := cg.resolveHistoricalDays(&common.CurrencyRatesTicker{ + Timestamp: time.Now().AddDate(0, 0, -1), + }, false) + if !shouldRequest || days != "1" { + 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) + } + }) + + t.Run("multi-day gap is backfill", 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 backfill result: days=%q shouldRequest=%v", days, shouldRequest) + } + if rangeKind != coingeckoRangeBackfill { + t.Fatalf("unexpected range kind: got %q, want %q", rangeKind, coingeckoRangeBackfill) + } + }) +} + +func TestSourceURLForRange(t *testing.T) { + cdn := "https://cdn.trezor.io/dynamic/coingecko/api/v3" + tip := "https://api.coingecko.com/api/v3" + withCDN := &Coingecko{tipURL: tip, bootstrapURL: cdn} + noCDN := &Coingecko{tipURL: tip} + + tests := []struct { + name string + cg *Coingecko + rangeKind string + want string + }{ + {name: "tip routes to CDN when configured", cg: withCDN, rangeKind: coingeckoRangeTip, want: cdn}, + {name: "backfill routes to CDN", cg: withCDN, rangeKind: coingeckoRangeBackfill, want: cdn}, + {name: "capped routes to CDN", cg: withCDN, rangeKind: coingeckoRangeCapped, want: cdn}, + {name: "historical routes to CDN", cg: withCDN, rangeKind: coingeckoRangeHistorical, want: cdn}, + {name: "backfill falls back to tip without CDN", cg: noCDN, rangeKind: coingeckoRangeBackfill, want: tip}, + {name: "tip without CDN stays on free tier", cg: noCDN, rangeKind: coingeckoRangeTip, want: tip}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cg.sourceURLForRange(tt.rangeKind); got != tt.want { + t.Fatalf("sourceURLForRange(%q) = %q, want %q", tt.rangeKind, got, tt.want) + } + }) + } +} + +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 TestUpdateHistoricalTickers_CloudflareBanStopsPassAndStoresPartial(t *testing.T) { + config := common.Config{ + CoinName: "fakecoin", + } + d, _, tmp := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, &config) + defer closeAndDestroyRocksDB(t, d, tmp) + + // steady state (bootstrap already complete) so the pass targets the tip URL + if err := d.FiatRatesSetHistoricalBootstrapComplete(true); err != nil { + t.Fatalf("FiatRatesSetHistoricalBootstrapComplete failed: %v", err) + } + + // usd succeeds, eur returns a Cloudflare 1015 ban (served as a 403 HTML page) + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch r.URL.Path { + case "/simple/supported_vs_currencies": + return stubHTTPResponse(http.StatusOK, `["usd","eur"]`), nil + case "/coins/ethereum/market_chart": + switch r.URL.Query().Get("vs_currency") { + case "usd": + return stubHTTPResponse(http.StatusOK, `{"prices":[[1654732800000,1234.5]]}`), nil + case "eur": + return stubHTTPResponse(http.StatusForbidden, cloudflareBanBody), nil + } + return stubHTTPResponse(http.StatusBadRequest, "unexpected vs_currency"), nil + } + return stubHTTPResponse(http.StatusNotFound, "unexpected path "+r.URL.Path), nil + }) + + cg := &Coingecko{ + coin: "ethereum", + tipURL: "http://coingecko.test", + httpClient: &http.Client{Transport: transport}, + db: d, + plan: coingeckoPlanFree, + } + + err := cg.UpdateHistoricalTickers() + if err == nil { + t.Fatal("expected UpdateHistoricalTickers to return the Cloudflare ban error") + } + if !isCoingeckoCloudflareBanError(err) { + t.Fatalf("expected a Cloudflare ban error, got %v", err) + } + + // currencies processed before the ban must be persisted (partial progress) + 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 before the ban") + } + // the banned currency (and anything after it) must be absent; resumed next cycle + 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 after the ban, got %+v", eurTicker) + } +} + +func TestMakeReq_ThrottleRetriesWithoutCap(t *testing.T) { + originalBackoff := coingeckoThrottleBackoff + coingeckoThrottleBackoff = 0 + defer func() { + coingeckoThrottleBackoff = originalBackoff + }() + + // 429 more times than the old retry cap (4) to prove makeReq keeps retrying past it instead + // of giving up, then succeeds once the provider stops throttling. + throttleHits := 9 + var requests atomic.Int32 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if int(requests.Add(1)) <= throttleHits { + http.Error(w, "exceeded the rate limit", http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer mockServer.Close() + + cg := &Coingecko{ + httpClient: mockServer.Client(), + } + resp, err := cg.makeReq(context.Background(), mockServer.URL, "market_chart", coingeckoPlanFree, priorityHigh) + 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, want := int(requests.Load()), throttleHits+1; got != want { + t.Fatalf("unexpected number of requests: got %d, want %d", got, want) + } +} + +func TestMakeReq_ThrottleRetriesEventuallySuccess(t *testing.T) { + originalBackoff := coingeckoThrottleBackoff + coingeckoThrottleBackoff = 0 + defer func() { + coingeckoThrottleBackoff = 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(context.Background(), mockServer.URL, "market_chart", coingeckoPlanFree, priorityHigh) + 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 TestIsCoingeckoCloudflareBanError(t *testing.T) { + bans := []error{ + errors.New("error code: 1015"), + errors.New("Error Code: 1015"), // case-insensitive + &coingeckoHTTPError{status: http.StatusForbidden, body: cloudflareBanBody}, + &coingeckoHTTPError{status: http.StatusTooManyRequests, body: "error code: 1015"}, + fmt.Errorf("wrapped: %w", &coingeckoHTTPError{status: http.StatusForbidden, body: cloudflareBanBody}), + } + for _, err := range bans { + if !isCoingeckoCloudflareBanError(err) { + t.Fatalf("expected Cloudflare ban detection for %v", err) + } + } + + notBans := []error{ + nil, + errors.New("exceeded the rate limit"), // application-level 429, must stay retryable + &coingeckoHTTPError{status: http.StatusTooManyRequests, body: `{"msg":"slow down"}`}, + errors.New("could not find coin with the given id"), + } + for _, err := range notBans { + if isCoingeckoCloudflareBanError(err) { + t.Fatalf("did not expect Cloudflare ban detection for %v", err) + } + } +} + +func TestMakeReq_CloudflareBanFastFails(t *testing.T) { + // A genuine 1015 ban must fast-fail immediately (no retry), unlike a plain 429. + originalBackoff := coingeckoThrottleBackoff + coingeckoThrottleBackoff = 0 + defer func() { coingeckoThrottleBackoff = originalBackoff }() + + var requests atomic.Int32 + cg := &Coingecko{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + requests.Add(1) + return &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(cloudflareBanBody)), + Header: make(http.Header), + }, nil + }), + }, + } + + resp, err := cg.makeReq(context.Background(), "http://coingecko.invalid/coins/x/market_chart", "market_chart", coingeckoPlanFree, priorityLow) + if err == nil { + t.Fatal("expected makeReq to fast-fail on a Cloudflare 1015 ban, got nil error") + } + if !isCoingeckoCloudflareBanError(err) { + t.Fatalf("expected a Cloudflare ban error, got %v", err) + } + if resp != nil { + t.Fatalf("expected nil response on ban, got %q", string(resp)) + } + if got := int(requests.Load()); got != 1 { + t.Fatalf("expected exactly 1 request (no retry on a ban), got %d", got) + } +} + +func TestParseRetryAfter(t *testing.T) { + tests := []struct { + name string + value string + want time.Duration + }{ + {"seconds", "5", 5 * time.Second}, + {"trimmed seconds", " 10 ", 10 * time.Second}, + {"zero", "0", 0}, + {"negative", "-3", 0}, + {"empty", "", 0}, + {"garbage", "soon", 0}, + {"http-date unsupported", "Wed, 10 Jun 2026 11:34:33 GMT", 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseRetryAfter(tt.value); got != tt.want { + t.Fatalf("parseRetryAfter(%q) = %v, want %v", tt.value, got, tt.want) + } + }) + } +} + +func TestRetryAfterFromError(t *testing.T) { + if got := retryAfterFromError(&coingeckoHTTPError{status: http.StatusTooManyRequests, retryAfter: 7 * time.Second}); got != 7*time.Second { + t.Fatalf("retryAfterFromError direct = %v, want 7s", got) + } + // errors.As must unwrap wrapped errors to reach the underlying Retry-After. + wrapped := fmt.Errorf("wrapped: %w", &coingeckoHTTPError{status: http.StatusTooManyRequests, retryAfter: 7 * time.Second}) + if got := retryAfterFromError(wrapped); got != 7*time.Second { + t.Fatalf("retryAfterFromError wrapped = %v, want 7s", got) + } + if got := retryAfterFromError(errors.New("boom")); got != 0 { + t.Fatalf("retryAfterFromError non-http = %v, want 0", got) + } +} + +func TestThrottleBackoffDelay(t *testing.T) { + originalBackoff := coingeckoThrottleBackoff + coingeckoThrottleBackoff = 10 * time.Second + defer func() { coingeckoThrottleBackoff = originalBackoff }() + + within := func(t *testing.T, got, base time.Duration) { + t.Helper() + upper := base + base/5 // base + up to ~20% jitter + if got < base || got > upper { + t.Fatalf("delay %v out of expected [%v, %v]", got, base, upper) + } + } + + noRetryAfter := &coingeckoHTTPError{status: http.StatusTooManyRequests} + // The fixed backoff is used only when there is no Retry-After hint. + within(t, throttleBackoffDelay(noRetryAfter), 10*time.Second) + // A Retry-After hint takes priority over the fixed backoff, whether it is longer... + within(t, throttleBackoffDelay(&coingeckoHTTPError{status: http.StatusTooManyRequests, retryAfter: 30 * time.Second}), 30*time.Second) + // ...or shorter than the fixed backoff. + within(t, throttleBackoffDelay(&coingeckoHTTPError{status: http.StatusTooManyRequests, retryAfter: 5 * time.Second}), 5*time.Second) + + // A zeroed backoff with no Retry-After stays instant (keeps the throttle tests fast). + coingeckoThrottleBackoff = 0 + if got := throttleBackoffDelay(noRetryAfter); got != 0 { + t.Fatalf("zeroed backoff delay = %v, want 0", got) + } +} + +func TestDoReq_ParsesRetryAfter(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header()["retry-after"] = []string{"5"} + http.Error(w, "exceeded the rate limit", http.StatusTooManyRequests) + })) + defer mockServer.Close() + + req, err := http.NewRequest("GET", mockServer.URL, nil) + if err != nil { + t.Fatalf("NewRequest failed: %v", err) + } + _, err = doReq(req, mockServer.Client()) + var httpErr *coingeckoHTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("expected coingeckoHTTPError, got %v", err) + } + if httpErr.status != http.StatusTooManyRequests { + t.Fatalf("unexpected status: %d", httpErr.status) + } + if httpErr.retryAfter != 5*time.Second { + t.Fatalf("unexpected retryAfter: %v, want 5s", httpErr.retryAfter) + } +} + +func TestIsCoingeckoThrottleError_StatusCode(t *testing.T) { + // A 429 must be detected even when the body has none of the legacy throttle keywords. + if !isCoingeckoThrottleError(&coingeckoHTTPError{status: http.StatusTooManyRequests, body: `{"msg":"slow down"}`}) { + t.Fatal("expected 429 to be detected as throttle") + } + if isCoingeckoThrottleError(&coingeckoHTTPError{status: http.StatusInternalServerError, body: "boom"}) { + t.Fatal("did not expect 500 to be detected as throttle") + } + // Legacy keyless-endpoint signal still matches via body text. + if !isCoingeckoThrottleError(errors.New("error code: 1015")) { + t.Fatal("expected Cloudflare 1015 to be detected as throttle") + } + // Cloudflare actually returns the 1015 code inside a full HTML page, so the + // substring must be detected even when it is not the entire error string. + cloudflareBody := "The owner of this website has banned you temporarily. error code: 1015" + if !isCoingeckoThrottleError(&coingeckoHTTPError{status: http.StatusForbidden, body: cloudflareBody}) { + t.Fatal("expected Cloudflare 1015 HTML page to be detected as throttle") + } + if isCoingeckoThrottleError(nil) { + t.Fatal("nil error must not be a throttle error") + } +} + +func TestMakeReq_Detects429ByStatus(t *testing.T) { + originalBackoff := coingeckoThrottleBackoff + coingeckoThrottleBackoff = 0 + defer func() { + coingeckoThrottleBackoff = originalBackoff + }() + + var requests atomic.Int32 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 429 with a body that contains none of the legacy keywords; detection must rely on status. + if requests.Add(1) <= 2 { + http.Error(w, `{"error":"please slow down"}`, http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer mockServer.Close() + + cg := &Coingecko{httpClient: mockServer.Client()} + resp, err := cg.makeReq(context.Background(), mockServer.URL, "market_chart", coingeckoPlanFree, priorityHigh) + 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 TestMakeReq_PacesRequests(t *testing.T) { + interval := 60 * time.Millisecond + var requests atomic.Int32 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer mockServer.Close() + + cg := &Coingecko{ + httpClient: mockServer.Client(), + minHttpRequestInterval: interval, + } + start := time.Now() + for i := 0; i < 3; i++ { + if _, err := cg.makeReq(context.Background(), mockServer.URL, "simple/price", coingeckoPlanFree, priorityHigh); err != nil { + t.Fatalf("makeReq failed on call %d: %v", i, err) + } + } + elapsed := time.Since(start) + // 3 requests => 2 enforced gaps of at least `interval` each. + if minElapsed := 2 * interval; elapsed < minElapsed { + t.Fatalf("expected pacing to take at least %v across 3 requests, took %v", minElapsed, elapsed) + } + if got := int(requests.Load()); got != 3 { + t.Fatalf("unexpected number of requests: got %d, want %d", got, 3) + } +} + +// The bootstrap CDN has no CoinGecko rate limit, so requests to it must use the light +// bootstrap spacing instead of the (potentially multi-second) plan pacing. Otherwise the +// free-plan 6s spacing would stretch the initial bootstrap from minutes to hours. +func TestMakeReq_BootstrapCDNBypassesPlanPacing(t *testing.T) { + var requests atomic.Int32 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer mockServer.Close() + + cg := &Coingecko{ + httpClient: mockServer.Client(), + minHttpRequestInterval: 10 * time.Second, // plan pacing, must NOT apply to the CDN + bootstrapRequestInterval: 0, + bootstrapURL: mockServer.URL, + } + start := time.Now() + for i := 0; i < 3; i++ { + if _, err := cg.makeReq(context.Background(), mockServer.URL+"/coins/list", "coins/list", coingeckoPlanFree, priorityLow); err != nil { + t.Fatalf("makeReq failed on call %d: %v", i, err) + } + } + elapsed := time.Since(start) + // With plan pacing applied this would take >= 20s; the CDN path must be far faster. + if elapsed > time.Second { + t.Fatalf("bootstrap CDN requests were paced at the plan interval: 3 requests took %v", elapsed) + } + if got := int(requests.Load()); got != 3 { + t.Fatalf("unexpected number of requests: got %d, want %d", got, 3) + } +} + +// A configured bootstrap URL must not relax pacing for the rate-limited CoinGecko API +// hosts: only requests whose URL actually targets the CDN get the light spacing. +func TestMakeReq_NonBootstrapURLStillPacedWhenBootstrapConfigured(t *testing.T) { + interval := 60 * time.Millisecond + var requests atomic.Int32 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer mockServer.Close() + + cg := &Coingecko{ + httpClient: mockServer.Client(), + minHttpRequestInterval: interval, + bootstrapRequestInterval: 0, + bootstrapURL: "https://cdn.trezor.io/dynamic/coingecko/api/v3", // different host than mockServer + } + start := time.Now() + for i := 0; i < 3; i++ { + if _, err := cg.makeReq(context.Background(), mockServer.URL, "simple/price", coingeckoPlanFree, priorityHigh); err != nil { + t.Fatalf("makeReq failed on call %d: %v", i, err) + } + } + elapsed := time.Since(start) + if minElapsed := 2 * interval; elapsed < minElapsed { + t.Fatalf("expected pacing to take at least %v across 3 requests, took %v", minElapsed, elapsed) + } + if got := int(requests.Load()); got != 3 { + t.Fatalf("unexpected number of requests: got %d, want %d", got, 3) + } +} + +// cdn.trezor.io's Cloudflare bot protection rejects the default "Go-http-client/1.1" +// user-agent with a 403 challenge page, which stalled startup fiat-rate downloads. Every +// request must carry an explicit Blockbook user-agent so it is not mistaken for blocked +// automation traffic. +func TestMakeReq_SendsBlockbookUserAgent(t *testing.T) { + var gotUserAgent string + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUserAgent = r.Header.Get("User-Agent") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer mockServer.Close() + + cg := &Coingecko{httpClient: mockServer.Client()} + if _, err := cg.makeReq(context.Background(), mockServer.URL, "simple/price", coingeckoPlanFree, priorityHigh); err != nil { + t.Fatalf("makeReq failed: %v", err) + } + + if want := coingeckoUserAgent(); gotUserAgent != want { + t.Fatalf("unexpected User-Agent: got %q, want %q", gotUserAgent, want) + } + if !strings.HasPrefix(gotUserAgent, "blockbook/") { + t.Fatalf("User-Agent %q is not Blockbook-identifying", gotUserAgent) + } + // The default Go user-agent is exactly what the CDN's bot filter blocks, so guard against + // ever regressing back to it (an empty header lets net/http fall back to that default). + if gotUserAgent == "" || strings.HasPrefix(strings.ToLower(gotUserAgent), "go-http-client") { + t.Fatalf("User-Agent %q would be blocked by the CDN bot filter", gotUserAgent) + } +} + +func TestMarkThrottled_ExtendsNeverShortens(t *testing.T) { + cg := &Coingecko{} + cg.markThrottled(time.Minute) + first := cg.throttledUntil + if first.IsZero() { + t.Fatal("markThrottled did not open the throttle window") + } + // a shorter delay must not shorten the already open window + cg.markThrottled(time.Second) + if !cg.throttledUntil.Equal(first) { + t.Fatalf("shorter delay moved the window: %v -> %v", first, cg.throttledUntil) + } + // a longer delay extends it + cg.markThrottled(2 * time.Minute) + if !cg.throttledUntil.After(first) { + t.Fatalf("longer delay did not extend the window past %v: %v", first, cg.throttledUntil) + } +} + +func TestWaitForRequestSlot_HighPriorityWaitsOutWindowButIsNotParked(t *testing.T) { + cg := &Coingecko{ + throttledUntil: time.Now().Add(100 * time.Millisecond), + highPriorityInFlight: 1, // a concurrent high-priority request must not park another one + } + until := cg.throttledUntil + done := make(chan time.Time, 1) + go func() { + cg.waitForRequestSlot(context.Background(), priorityHigh, cg.minHttpRequestInterval) + done <- time.Now() + }() + select { + case returnedAt := <-done: + if returnedAt.Before(until) { + t.Fatalf("high priority request fired at %v, before the throttle window cleared at %v", returnedAt, until) + } + case <-time.After(10 * time.Second): + t.Fatal("high priority request was parked; it must only wait out the throttle window") + } +} + +func TestWaitForRequestSlot_LowPriorityParksUntilHighPriorityCompletes(t *testing.T) { + originalPoll := coingeckoLowPriorityPollInterval + coingeckoLowPriorityPollInterval = time.Millisecond + defer func() { coingeckoLowPriorityPollInterval = originalPoll }() + + cg := &Coingecko{ + throttledUntil: time.Now().Add(10 * time.Second), + highPriorityInFlight: 1, + } + done := make(chan struct{}) + go func() { + cg.waitForRequestSlot(context.Background(), priorityLow, cg.minHttpRequestInterval) + close(done) + }() + // while throttled with a high-priority request in flight, low priority must stay parked + time.Sleep(20 * time.Millisecond) + select { + case <-done: + t.Fatal("low priority request fired while throttled with a high priority request in flight") + default: + } + // once the high-priority request completes and the window clears, low priority proceeds + cg.reqMu.Lock() + cg.highPriorityInFlight = 0 + cg.throttledUntil = time.Now() + cg.reqMu.Unlock() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("low priority request stayed parked after the gate cleared") + } +} + +func TestWaitForRequestSlot_RechecksWindowExtendedDuringSleep(t *testing.T) { + cg := &Coingecko{} + cg.markThrottled(200 * time.Millisecond) + done := make(chan time.Time, 1) + go func() { + cg.waitForRequestSlot(context.Background(), priorityHigh, cg.minHttpRequestInterval) + done <- time.Now() + }() + // wait until the goroutine reserved its slot inside the first window + for { + cg.reqMu.Lock() + reserved := !cg.lastRequestAt.IsZero() + cg.reqMu.Unlock() + if reserved { + break + } + time.Sleep(time.Millisecond) + } + // extend the window while the goroutine sleeps on its already-reserved slot + cg.markThrottled(500 * time.Millisecond) + cg.reqMu.Lock() + extendedUntil := cg.throttledUntil + cg.reqMu.Unlock() + returnedAt := <-done + if returnedAt.Before(extendedUntil) { + t.Fatalf("request fired at %v, inside the extended throttle window ending %v", returnedAt, extendedUntil) + } +} + +// A 429 received by one request opens a throttle window that a second, unrelated request +// waits out as well. +func TestMakeReq_SharedThrottleWindow(t *testing.T) { + originalBackoff := coingeckoThrottleBackoff + coingeckoThrottleBackoff = 100 * time.Millisecond + defer func() { coingeckoThrottleBackoff = originalBackoff }() + + var requests atomic.Int32 + var windowUntil atomic.Int64 // unix nanos of the shared window once opened + var violations atomic.Int32 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if until := windowUntil.Load(); until != 0 && time.Now().UnixNano() < until { + violations.Add(1) + } + if requests.Add(1) == 1 { + http.Error(w, "exceeded the rate limit", http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer mockServer.Close() + + cg := &Coingecko{httpClient: mockServer.Client()} + firstDone := make(chan error, 1) + go func() { + _, err := cg.makeReq(context.Background(), mockServer.URL, "market_chart", coingeckoPlanFree, priorityLow) + firstDone <- err + }() + // wait until the first request's 429 opened the shared window + for { + cg.reqMu.Lock() + until := cg.throttledUntil + cg.reqMu.Unlock() + if !until.IsZero() { + windowUntil.Store(until.UnixNano()) + break + } + time.Sleep(time.Millisecond) + } + // a second request on another endpoint must wait out the shared window too + if _, err := cg.makeReq(context.Background(), mockServer.URL, "simple/price", coingeckoPlanFree, priorityHigh); err != nil { + t.Fatalf("second makeReq failed: %v", err) + } + if err := <-firstDone; err != nil { + t.Fatalf("first makeReq failed: %v", err) + } + if violations.Load() != 0 { + t.Fatal("a request reached the server inside the shared throttle window") + } +} + +// While a high-priority request keeps retrying through a 429 storm, a concurrent +// low-priority request stays parked and reaches the server only after the high-priority +// request succeeded. +func TestMakeReq_LowPriorityYieldsToHighDuringThrottle(t *testing.T) { + originalBackoff := coingeckoThrottleBackoff + originalPoll := coingeckoLowPriorityPollInterval + coingeckoThrottleBackoff = 60 * time.Millisecond + coingeckoLowPriorityPollInterval = time.Millisecond + defer func() { + coingeckoThrottleBackoff = originalBackoff + coingeckoLowPriorityPollInterval = originalPoll + }() + + var highThrottleHits atomic.Int32 + var highSucceededAt atomic.Int64 + var lowArrivedAt atomic.Int64 + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/high": + if highThrottleHits.Add(1) <= 2 { + http.Error(w, "exceeded the rate limit", http.StatusTooManyRequests) + return + } + highSucceededAt.Store(time.Now().UnixNano()) + case "/low": + lowArrivedAt.Store(time.Now().UnixNano()) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer mockServer.Close() + + cg := &Coingecko{ + httpClient: mockServer.Client(), + // spacing larger than the loopback round trip keeps an unparked low-priority + // request from firing in the instant between a window expiring and the retrying + // high-priority request extending it + minHttpRequestInterval: 20 * time.Millisecond, + } + highDone := make(chan error, 1) + go func() { + _, err := cg.makeReq(context.Background(), mockServer.URL+"/high", "market_chart", coingeckoPlanFree, priorityHigh) + highDone <- err + }() + // wait until the high-priority request opened the window (it stays in flight while retrying) + for { + cg.reqMu.Lock() + opened := !cg.throttledUntil.IsZero() + cg.reqMu.Unlock() + if opened { + break + } + time.Sleep(time.Millisecond) + } + lowDone := make(chan error, 1) + go func() { + _, err := cg.makeReq(context.Background(), mockServer.URL+"/low", "simple/price", coingeckoPlanFree, priorityLow) + lowDone <- err + }() + if err := <-highDone; err != nil { + t.Fatalf("high priority makeReq failed: %v", err) + } + if err := <-lowDone; err != nil { + t.Fatalf("low priority makeReq failed: %v", err) + } + high, low := highSucceededAt.Load(), lowArrivedAt.Load() + if high == 0 || low == 0 { + t.Fatal("expected both endpoints to be hit") + } + if low < high { + t.Fatalf("low priority request reached the server %v before the high priority request succeeded", time.Duration(high-low)) + } +} + +// Throttled requests are retried without an attempt cap, so a provider-wide 429 no longer ends +// the pass: makeReq keeps backing off until the request succeeds, then the pass proceeds through +// the remaining currencies. Every currency is eventually fetched and stored. +func TestUpdateHistoricalTickers_RetriesThrottleUntilSuccess(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) + } + originalBackoff := coingeckoThrottleBackoff + coingeckoThrottleBackoff = 0 + defer func() { + coingeckoThrottleBackoff = originalBackoff + }() + + // Throttle usd more times than the old retry cap (4) to prove the attempt cap is gone. + throttleHits := 7 + 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": + if int(usdRequests.Add(1)) <= throttleHits { + http.Error(w, "exceeded the rate limit", http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{"prices":[[1654732800000,1111.1]]}`)) + 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, + } + + if err := cg.UpdateHistoricalTickers(); err != nil { + t.Fatalf("UpdateHistoricalTickers returned error: %v", err) + } + + // usd is retried past the old attempt cap until it finally succeeds. + if got, want := int(usdRequests.Load()), throttleHits+1; got != want { + t.Fatalf("unexpected usd request count: got %d, want %d", got, want) + } + // once usd succeeds the pass continues to eur (no break on throttle anymore). + if got := int(eurRequests.Load()); got != 1 { + t.Fatalf("unexpected eur request count: got %d, want 1", got) + } + for _, cur := range []string{"usd", "eur"} { + ticker, err := d.FiatRatesFindLastTicker(cur, "") + if err != nil { + t.Fatalf("FiatRatesFindLastTicker %s failed: %v", cur, err) + } + if ticker == nil { + t.Fatalf("expected %s ticker to be stored after retry-until-success", cur) + } + } +} + +// Even during bootstrap, throttled requests are retried without an attempt cap: the pass waits +// out the provider-wide 429 instead of failing the bootstrap cycle. Once the requests succeed, +// every currency is fetched and the bootstrap pass completes without error. +func TestUpdateHistoricalTickers_RetriesThrottleUntilSuccessDuringBootstrap(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) + } + originalBackoff := coingeckoThrottleBackoff + coingeckoThrottleBackoff = 0 + defer func() { + coingeckoThrottleBackoff = originalBackoff + }() + + // Throttle usd more times than the old retry cap (4) to prove the attempt cap is gone. + throttleHits := 7 + 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": + if int(usdRequests.Add(1)) <= throttleHits { + http.Error(w, "exceeded the rate limit", http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{"prices":[[1654732800000,1111.1]]}`)) + 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, + } + + if err := cg.UpdateHistoricalTickers(); err != nil { + t.Fatalf("UpdateHistoricalTickers returned error during bootstrap: %v", err) + } + + // usd is retried past the old attempt cap until it finally succeeds. + if got, want := int(usdRequests.Load()), throttleHits+1; got != want { + t.Fatalf("unexpected usd request count: got %d, want %d", got, want) + } + // once usd succeeds the bootstrap pass continues to eur (no break on throttle anymore). + if got := int(eurRequests.Load()); got != 1 { + t.Fatalf("unexpected eur request count: got %d, want 1", got) + } + for _, cur := range []string{"usd", "eur"} { + ticker, err := d.FiatRatesFindLastTicker(cur, "") + if err != nil { + t.Fatalf("FiatRatesFindLastTicker %s failed: %v", cur, err) + } + if ticker == nil { + t.Fatalf("expected %s ticker to be stored after retry-until-success", cur) + } + } +} + +// Throttled requests are retried without an attempt cap, so a provider-wide 429 no longer stops +// the token pass after the first throttled token: makeReq waits out the 429 until the request +// succeeds, and the pass then continues through the remaining tokens. +func TestUpdateHistoricalTokenTickers_RetriesThrottleUntilSuccess(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) + } + originalBackoff := coingeckoThrottleBackoff + coingeckoThrottleBackoff = 0 + defer func() { + coingeckoThrottleBackoff = originalBackoff + }() + + // Throttle the token requests more times than the old retry cap (4) to prove the cap is gone. + throttleHits := 7 + 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": + if int(marketChartRequests.Add(1)) <= throttleHits { + http.Error(w, "exceeded the rate limit", http.StatusTooManyRequests) + return + } + _, _ = w.Write([]byte(`{"prices":[[1654732800000,1.5]]}`)) + 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, + } + + if err := cg.UpdateHistoricalTokenTickers(); err != nil { + t.Fatalf("UpdateHistoricalTokenTickers returned error: %v", err) + } + + // The first token absorbs all the throttles (retried past the old cap until it succeeds); the + // remaining token then succeeds on its first request. No token is dropped on a 429. + if got, want := int(marketChartRequests.Load()), throttleHits+2; got != want { + t.Fatalf("unexpected market_chart request count: got %d, want %d", got, want) + } +} + +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") + } +} + +// Reconciliation fills an interior missing day (no record at all) for both the base coin and +// a token, fetching from the CDN, and must touch only the missing day (existing days intact). +func TestReconcileHistoricalRates_RepairsInteriorHole(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) + } + + // present: d2, d4, d5 (all older than the tip); d3 is an interior hole (no record). + d2, d3, d4, d5 := midnightDaysAgo(2), midnightDaysAgo(3), midnightDaysAgo(4), midnightDaysAgo(5) + seedDailyTicker(t, d, d2, map[string]float32{"usd": 222}, nil) + seedDailyTicker(t, d, d4, map[string]float32{"usd": 444}, nil) + seedDailyTicker(t, d, d5, map[string]float32{"usd": 555}, nil) + + 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"]`)) + case "/coins/list": + _, _ = w.Write([]byte(`[{"id":"ethereum","symbol":"eth","name":"Ethereum","platforms":{}},{"id":"token-a","symbol":"ta","name":"Token A","platforms":{"ethereum":"0xabc"}}]`)) + case "/coins/ethereum/market_chart": + // d2 returns 999 to prove the present day is not touched (only d3 is wanted). + body := fmt.Sprintf(`{"prices":[[%d,999],[%d,333],[%d,888]]}`, + d2.Unix()*1000, d3.Unix()*1000, d4.Unix()*1000) + _, _ = w.Write([]byte(body)) + case "/coins/token-a/market_chart": + body := fmt.Sprintf(`{"prices":[[%d,0.5]]}`, d3.Unix()*1000) + _, _ = w.Write([]byte(body)) + default: + http.Error(w, "unexpected path "+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, + } + + filled, err := cg.ReconcileHistoricalRates(context.Background(), 365, 90) + if err != nil { + t.Fatalf("ReconcileHistoricalRates failed: %v", err) + } + if filled != 2 { // usd + token on d3 + t.Fatalf("unexpected filled points: got %d, want 2", filled) + } + + t3, err := d.FiatRatesGetTicker(&d3) + if err != nil { + t.Fatalf("FiatRatesGetTicker d3 failed: %v", err) + } + if t3 == nil || t3.Rates["usd"] != 333 { + t.Fatalf("interior hole base rate not repaired: got %+v, want usd=333", t3) + } + if t3.TokenRates["0xabc"] != 0.5 { + t.Fatalf("interior hole token rate not repaired: got %+v, want 0xabc=0.5", t3.TokenRates) + } + t2, err := d.FiatRatesGetTicker(&d2) + if err != nil { + t.Fatalf("FiatRatesGetTicker d2 failed: %v", err) + } + if t2 == nil || t2.Rates["usd"] != 222 { + t.Fatalf("present day was modified: got %+v, want usd=222", t2) + } +} + +// A healthy DB (no missing days in the window) must make no network requests at all. +func TestReconcileHistoricalRates_HealthyMakesNoRequests(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) + } + // continuous coverage from d5 to d2 (the last reconcilable day) -> no gaps + for _, n := range []int{2, 3, 4, 5} { + seedDailyTicker(t, d, midnightDaysAgo(n), map[string]float32{"usd": 100}, nil) + } + + called := roundTripFunc(func(r *http.Request) (*http.Response, error) { + t.Errorf("healthy DB must not issue requests, got %s", r.URL) + return stubHTTPResponse(http.StatusNotFound, ""), nil + }) + cg := &Coingecko{ + coin: "ethereum", + bootstrapURL: "https://cdn.trezor.io/dynamic/coingecko/api/v3", + tipURL: "http://coingecko.test", + httpClient: &http.Client{Transport: called}, + db: d, + plan: coingeckoPlanFree, + } + + filled, err := cg.ReconcileHistoricalRates(context.Background(), 365, 90) + if err != nil { + t.Fatalf("ReconcileHistoricalRates failed: %v", err) + } + if filled != 0 { + t.Fatalf("healthy DB should fill nothing, got %d", filled) + } +} + +// A young DB whose only records are in the tip zone (today/yesterday) is not a gap and must +// not be flagged or fetched. +func TestReconcileHistoricalRates_YoungDBMakesNoRequests(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) + } + // only tip-zone records exist (today, yesterday) -> nothing older to reconcile + seedDailyTicker(t, d, midnightDaysAgo(0), map[string]float32{"usd": 100}, nil) + seedDailyTicker(t, d, midnightDaysAgo(1), map[string]float32{"usd": 100}, nil) + + called := roundTripFunc(func(r *http.Request) (*http.Response, error) { + t.Errorf("young DB must not issue requests, got %s", r.URL) + return stubHTTPResponse(http.StatusNotFound, ""), nil + }) + cg := &Coingecko{ + coin: "ethereum", + bootstrapURL: "https://cdn.trezor.io/dynamic/coingecko/api/v3", + tipURL: "http://coingecko.test", + httpClient: &http.Client{Transport: called}, + db: d, + plan: coingeckoPlanFree, + } + + filled, err := cg.ReconcileHistoricalRates(context.Background(), 365, 90) + if err != nil { + t.Fatalf("ReconcileHistoricalRates failed: %v", err) + } + if filled != 0 { + t.Fatalf("young DB should fill nothing, got %d", filled) + } +} + +// When the number of missing days reaches the guard it is flagged as gap_too_large and +// nothing is fetched (not even the series enumeration), avoiding a backfill storm on a +// probably-broken DB. +func TestReconcileHistoricalRates_GapTooLargeSkipsFetch(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) + } + // only two records far apart -> ~197 missing days between them, well over the 90 guard + seedDailyTicker(t, d, midnightDaysAgo(2), map[string]float32{"usd": 100}, nil) + seedDailyTicker(t, d, midnightDaysAgo(200), map[string]float32{"usd": 100}, nil) + + called := roundTripFunc(func(r *http.Request) (*http.Response, error) { + t.Errorf("gap_too_large must not issue requests, got %s", r.URL) + return stubHTTPResponse(http.StatusNotFound, ""), nil + }) + cg := &Coingecko{ + coin: "ethereum", + bootstrapURL: "https://cdn.trezor.io/dynamic/coingecko/api/v3", + tipURL: "http://coingecko.test", + httpClient: &http.Client{Transport: called}, + db: d, + plan: coingeckoPlanFree, + } + + filled, err := cg.ReconcileHistoricalRates(context.Background(), 365, 90) + if err != nil { + t.Fatalf("ReconcileHistoricalRates failed: %v", err) + } + if filled != 0 { + t.Fatalf("gap_too_large should fill nothing, got %d", filled) + } +} + +// Reconciliation is a no-op when no CDN URL is configured (must not hit any endpoint). +func TestReconcileHistoricalRates_NoCDNIsNoOp(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) + } + + called := roundTripFunc(func(r *http.Request) (*http.Response, error) { + t.Errorf("no HTTP request expected without a CDN URL, got %s", r.URL) + return stubHTTPResponse(http.StatusNotFound, ""), nil + }) + cg := &Coingecko{ + coin: "ethereum", + tipURL: "http://coingecko.test", + httpClient: &http.Client{Transport: called}, + db: d, + plan: coingeckoPlanFree, + } + + if _, err := cg.ReconcileHistoricalRates(context.Background(), 365, 90); err != nil { + t.Fatalf("ReconcileHistoricalRates failed: %v", err) + } +} + +// An already-cancelled context (shutdown or budget timeout) aborts the pass without issuing +// any request, even when there is a genuine (sub-guard) gap to repair. +func TestReconcileHistoricalRates_CancelledContextAbortsBeforeFetch(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) + } + // a real interior hole at d3 (present d1,d2,d4,d5) -> work to do, but well under the guard + seedDailyTicker(t, d, midnightDaysAgo(5), map[string]float32{"usd": 100}, nil) + seedDailyTicker(t, d, midnightDaysAgo(4), map[string]float32{"usd": 100}, nil) + seedDailyTicker(t, d, midnightDaysAgo(2), map[string]float32{"usd": 100}, nil) + seedDailyTicker(t, d, midnightDaysAgo(1), map[string]float32{"usd": 100}, nil) + + called := roundTripFunc(func(r *http.Request) (*http.Response, error) { + t.Errorf("shutdown before backfill must not issue requests, got %s", r.URL) + return stubHTTPResponse(http.StatusNotFound, ""), nil + }) + cg := &Coingecko{ + coin: "ethereum", + bootstrapURL: "https://cdn.trezor.io/dynamic/coingecko/api/v3", + tipURL: "http://coingecko.test", + httpClient: &http.Client{Transport: called}, + db: d, + plan: coingeckoPlanFree, + } + + // an already-cancelled context simulates an in-flight shutdown / elapsed budget + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + filled, err := cg.ReconcileHistoricalRates(ctx, 365, 90) + if err != nil { + t.Fatalf("ReconcileHistoricalRates failed: %v", err) + } + if filled != 0 { + t.Fatalf("aborted reconciliation should fill nothing, got %d", filled) + } +} + +// When the wall-clock budget elapses while a backfill fetch is in flight, the pass aborts +// (without error) instead of stalling startup: metadata succeeds fast, the first chart fetch +// blocks until the budget cancels the request context. +func TestReconcileHistoricalRates_BudgetExhaustedAbortsMidBackfill(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) + } + // a real interior hole at d3 -> work to do, well under the gap guard + seedDailyTicker(t, d, midnightDaysAgo(2), map[string]float32{"usd": 100}, nil) + seedDailyTicker(t, d, midnightDaysAgo(4), map[string]float32{"usd": 100}, nil) + seedDailyTicker(t, d, midnightDaysAgo(5), map[string]float32{"usd": 100}, nil) + + var chartReqs 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"]`)) // metadata returns immediately + case "/coins/ethereum/market_chart": + atomic.AddInt32(&chartReqs, 1) + <-r.Context().Done() // block until the reconcile budget cancels the request + default: + http.Error(w, "unexpected path "+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, + } + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + filled, err := cg.ReconcileHistoricalRates(ctx, 365, 90) + if err != nil { + t.Fatalf("budget-exhausted reconciliation must not return an error, got: %v", err) + } + if filled != 0 { + t.Fatalf("a fetch cut short by the budget should fill nothing, got %d", filled) + } + if atomic.LoadInt32(&chartReqs) == 0 { + t.Fatal("expected the backfill loop to attempt at least one market_chart fetch before the budget expired") + } +} + +// A budget/shutdown abort during the token phase must NOT persist the base-only day records +// already accumulated by the (finished) base phase. If it did, stage-1's whole-day key-only scan +// would treat those days as "present" next startup and never reconcile their token rates again. +// The aborted pass must discard everything so the gap is re-reconciled in full later. (The same +// discard path covers a base-phase abort, which would persist partial base rates.) +func TestReconcileHistoricalRates_TokenPhaseAbortDiscardsBaseOnly(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) + } + // present d2, d4, d5; d3 is the interior hole to repair. + d2, d3, d4, d5 := midnightDaysAgo(2), midnightDaysAgo(3), midnightDaysAgo(4), midnightDaysAgo(5) + seedDailyTicker(t, d, d2, map[string]float32{"usd": 222}, nil) + seedDailyTicker(t, d, d4, map[string]float32{"usd": 444}, nil) + seedDailyTicker(t, d, d5, map[string]float32{"usd": 555}, nil) + + var baseFetched 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"]`)) + case "/coins/list": + _, _ = w.Write([]byte(`[{"id":"ethereum","symbol":"eth","name":"Ethereum","platforms":{}},{"id":"token-a","symbol":"ta","name":"Token A","platforms":{"ethereum":"0xabc"}}]`)) + case "/coins/ethereum/market_chart": + // base phase succeeds, filling d3's base rate into the shared map + atomic.AddInt32(&baseFetched, 1) + _, _ = w.Write([]byte(fmt.Sprintf(`{"prices":[[%d,333]]}`, d3.Unix()*1000))) + case "/coins/token-a/market_chart": + // token phase blocks until the budget cancels the request -> abort mid-token-phase + <-r.Context().Done() + default: + http.Error(w, "unexpected path "+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, + } + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + + filled, err := cg.ReconcileHistoricalRates(ctx, 365, 90) + if err != nil { + t.Fatalf("token-phase abort must not return an error, got: %v", err) + } + if filled != 0 { + t.Fatalf("aborted reconciliation must persist nothing, got filled=%d", filled) + } + if atomic.LoadInt32(&baseFetched) == 0 { + t.Fatal("expected the base phase to run (and fill d3) before the abort") + } + // Key assertion: the base-only d3 record must NOT have been persisted, so d3 stays a hole and + // is re-reconciled later instead of being silently frozen without its token rate. + t3, err := d.FiatRatesGetTicker(&d3) + if err != nil { + t.Fatalf("FiatRatesGetTicker d3 failed: %v", err) + } + if t3 != nil { + t.Fatalf("base-only day was persisted on abort (token rates would be lost forever): %+v", t3) + } + // present days must remain untouched + t2, err := d.FiatRatesGetTicker(&d2) + if err != nil { + t.Fatalf("FiatRatesGetTicker d2 failed: %v", err) + } + if t2 == nil || t2.Rates["usd"] != 222 { + t.Fatalf("present day was modified: got %+v, want usd=222", t2) + } +} + +// A coin without tokens reconciles base rates only; a clean pass must still persist them +// (base-only is "complete" when no tokens are configured -- the discard-on-abort guard must not +// over-prune the normal completion path). +func TestReconcileHistoricalRates_NoTokensPersistsBaseOnly(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) + } + // present d2, d4, d5; d3 is the interior hole to repair. + d2, d3, d4, d5 := midnightDaysAgo(2), midnightDaysAgo(3), midnightDaysAgo(4), midnightDaysAgo(5) + seedDailyTicker(t, d, d2, map[string]float32{"usd": 222}, nil) + seedDailyTicker(t, d, d4, map[string]float32{"usd": 444}, nil) + seedDailyTicker(t, d, d5, map[string]float32{"usd": 555}, nil) + + 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"]`)) + case "/coins/ethereum/market_chart": + _, _ = w.Write([]byte(fmt.Sprintf(`{"prices":[[%d,333]]}`, d3.Unix()*1000))) + default: + http.Error(w, "unexpected path "+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, + } + + filled, err := cg.ReconcileHistoricalRates(context.Background(), 365, 90) + if err != nil { + t.Fatalf("ReconcileHistoricalRates failed: %v", err) + } + if filled != 1 { + t.Fatalf("unexpected filled points: got %d, want 1", filled) + } + t3, err := d.FiatRatesGetTicker(&d3) + if err != nil { + t.Fatalf("FiatRatesGetTicker d3 failed: %v", err) + } + if t3 == nil || t3.Rates["usd"] != 333 { + t.Fatalf("interior hole base rate not repaired: got %+v, want usd=333", t3) + } +} diff --git a/fiat/fiat_rates.go b/fiat/fiat_rates.go index c547316940..41ae6212ac 100644 --- a/fiat/fiat_rates.go +++ b/fiat/fiat_rates.go @@ -1,214 +1,776 @@ package fiat import ( + "context" "encoding/json" "errors" "fmt" - "reflect" + "math/rand" + "os" + "sort" + "strings" + "sync" "time" "github.com/golang/glog" - "github.com/syscoin/blockbook/db" + "github.com/trezor/blockbook/common" + "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 *db.CurrencyRatesTicker) +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 { - getTicker(timestamp *time.Time) (*db.CurrencyRatesTicker, error) - marketDataExists(timestamp *time.Time) (bool, error) + CurrentTickers() (*common.CurrencyRatesTicker, error) + HourlyTickers() (*[]common.CurrencyRatesTicker, error) + FiveMinutesTickers() (*[]common.CurrencyRatesTicker, error) + UpdateHistoricalTickers() error + UpdateHistoricalTokenTickers() error + ReconcileHistoricalRates(ctx context.Context, windowDays int, maxGapDays int) (int, error) } -// RatesDownloader stores FiatRates API parameters -type RatesDownloader struct { - periodSeconds time.Duration - db *db.RocksDB - startTime *time.Time // a starting timestamp for tests to be deterministic (time.Now() for production) - timeFormat string - callbackOnNewTicker OnNewFiatRatesTicker - downloader RatesDownloaderInterface +const ( + // reconcileWindowDays bounds how far back the startup self-healing pass repairs missing + // daily rates; reconcileMaxGapDays is the trailing-gap guard above which a series is + // treated as a probable bug and reported instead of refetched. + reconcileWindowDays = 365 + reconcileMaxGapDays = 90 + // defaultReconcileMaxDuration is the wall-clock budget for the blocking startup + // reconciliation; once it elapses the pass aborts (persisting what it fetched) so a slow or + // throttling CDN can never stall startup. Overridable via reconcileHistoricalMaxMinutes. + defaultReconcileMaxDuration = 5 * time.Minute +) + +// 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 + reconcileAtStartup bool + reconcileMaxDuration time.Duration + 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 } -// NewFiatRatesDownloader initiallizes the downloader for FiatRates API. -// If the startTime is nil, the downloader will start from the beginning. -func NewFiatRatesDownloader(db *db.RocksDB, apiType string, params string, startTime *time.Time, callback OnNewFiatRatesTicker) (*RatesDownloader, error) { - var rd = &RatesDownloader{} +var fiatRatesFindTickers = func(d *db.RocksDB, timestamps []int64, vsCurrency string, token string) ([]*common.CurrencyRatesTicker, error) { + return d.FiatRatesFindTickers(timestamps, vsCurrency, token) +} + +// 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"` - PeriodSeconds int `json:"periodSeconds"` + URL string `json:"url"` + Coin string `json:"coin"` + PlatformIdentifier string `json:"platformIdentifier"` + PlatformVsCurrency string `json:"platformVsCurrency"` + PeriodSeconds int64 `json:"periodSeconds"` + Plan string `json:"plan"` + // ReconcileHistoricalAtStartup toggles the blocking startup self-healing pass that + // repairs missing historical rates. Absent (nil) means enabled; set false to disable. + ReconcileHistoricalAtStartup *bool `json:"reconcileHistoricalAtStartup"` + // ReconcileHistoricalMaxMinutes caps the wall-clock time of that pass. Absent (nil) or + // <= 0 uses defaultReconcileMaxDuration. + ReconcileHistoricalMaxMinutes *int `json:"reconcileHistoricalMaxMinutes"` } 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 = time.Duration(rdParams.PeriodSeconds) * time.Second // Time period for syncing the latest market data - rd.db = db - rd.callbackOnNewTicker = callback - if startTime == nil { - timeNow := time.Now().UTC() - rd.startTime = &timeNow - } else { - rd.startTime = startTime // If startTime is nil, time.Now() will be used + 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 + } + fr.db = db + fr.metrics = metrics + fr.callbackOnNewTicker = callback + fr.downloadTokens = rdParams.PlatformIdentifier != "" && rdParams.PlatformVsCurrency != "" + fr.reconcileAtStartup = rdParams.ReconcileHistoricalAtStartup == nil || *rdParams.ReconcileHistoricalAtStartup + fr.reconcileMaxDuration = defaultReconcileMaxDuration + if rdParams.ReconcileHistoricalMaxMinutes != nil && *rdParams.ReconcileHistoricalMaxMinutes > 0 { + fr.reconcileMaxDuration = time.Duration(*rdParams.ReconcileHistoricalMaxMinutes) * time.Minute } - if apiType == "coingecko" { - rd.downloader = NewCoinGeckoDownloader(rdParams.URL, rdParams.Coin, rd.timeFormat) + if fr.downloadTokens { + common.TickerRecalculateTokenRate = strings.ToLower(db.GetInternalState().CoinShortcut) != rdParams.PlatformVsCurrency + common.TickerTokenVsCurrency = rdParams.PlatformVsCurrency + } + 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 + } + network := "" + coinShortcut := "" + if is != nil { + 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) } - return rd, nil + fr.logTickersInfo() + return fr, nil } -// Run starts the FiatRates downloader. If there are tickers available, it continues from the last record. -// If there are no tickers, it finds the earliest market data available on API and downloads historical data. -// When historical data is downloaded, it continues to fetch the latest ticker prices. -func (rd *RatesDownloader) Run() error { - var timestamp *time.Time +// 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 +} - // Check if there are any tickers stored in database - glog.Infof("Finding last available ticker...") - ticker, err := rd.db.FiatRatesFindLastTicker() - if err != nil { - glog.Errorf("RatesDownloader FindTicker error: %v", err) - return err +// 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 } - if ticker == nil { - // If no tickers found, start downloading from the beginning - glog.Infof("No tickers found! Looking up the earliest market data available on API and downloading from there.") - timestamp, err = rd.findEarliestMarketData() - if err != nil { - glog.Errorf("Error looking up earliest market data: %v", err) - return err + // 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 } - } else { - // If found, continue downloading data from the next day of the last available record - glog.Infof("Last available ticker: %v", ticker.Timestamp) - timestamp = ticker.Timestamp + uniqueMap[ts] = struct{}{} + uniqueTimestamps = append(uniqueTimestamps, ts) } - err = rd.syncHistorical(timestamp) + sort.Slice(uniqueTimestamps, func(i, j int) bool { + return uniqueTimestamps[i] < uniqueTimestamps[j] + }) + + foundTickers, err := fiatRatesFindTickers(fr.db, uniqueTimestamps, vsCurrency, token) if err != nil { - glog.Errorf("RatesDownloader syncHistorical error: %v", err) - return err + return nil, err } - if err := rd.syncLatest(); err != nil { - glog.Errorf("RatesDownloader syncLatest error: %v", 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 &tickers, nil +} +func (fr *FiatRates) logTickersInfo() { + // Snapshot the cache fields under the lock: this runs on the historical goroutine while the + // current goroutine concurrently replaces the hourly/5-minute maps and their bounds under + // fr.mux. Read them all atomically, then format the log line outside the lock. + fr.mux.RLock() + dailyLen, dailyFrom, dailyTo := len(fr.dailyTickers), fr.dailyTickersFrom, fr.dailyTickersTo + hourlyLen, hourlyFrom, hourlyTo := len(fr.hourlyTickers), fr.hourlyTickersFrom, fr.hourlyTickersTo + fiveMinLen, fiveMinFrom, fiveMinTo := len(fr.fiveMinutesTickers), fr.fiveMinutesTickersFrom, fr.fiveMinutesTickersTo + fr.mux.RUnlock() + glog.Infof("fiat rates %s handler, %d (%s - %s) daily tickers, %d (%s - %s) hourly tickers, %d (%s - %s) 5 minute tickers", fr.provider, + dailyLen, time.Unix(dailyFrom, 0).Format("2006-01-02"), time.Unix(dailyTo, 0).Format("2006-01-02"), + hourlyLen, time.Unix(hourlyFrom, 0).Format("2006-01-02 15:04"), time.Unix(hourlyTo, 0).Format("2006-01-02 15:04"), + fiveMinLen, time.Unix(fiveMinFrom, 0).Format("2006-01-02 15:04"), time.Unix(fiveMinTo, 0).Format("2006-01-02 15:04")) +} + +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 } -// FindEarliestMarketData uses binary search to find the oldest market data available on API. -func (rd *RatesDownloader) findEarliestMarketData() (*time.Time, error) { - minDateString := "03-01-2009" - minDate, err := time.Parse(rd.timeFormat, minDateString) - if err != nil { - glog.Error("Error parsing date: ", err) - return nil, err +// 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 } - maxDate := rd.startTime.Add(time.Duration(-24) * time.Hour) // today's historical tickers may not be ready yet, so set to yesterday - currentDate := maxDate - for { - var dataExists bool = false - for { - dataExists, err = rd.downloader.marketDataExists(¤tDate) - if err != nil { - glog.Errorf("Error checking if market data exists for date %v. Error: %v. Retrying in %v seconds.", currentDate, err, rd.periodSeconds) - timer := time.NewTimer(rd.periodSeconds) - <-timer.C + 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 + } + } } - break } - dateDiff := currentDate.Sub(minDate) - if dataExists { - if dateDiff < time.Hour*24 { - maxDate := time.Date(maxDate.Year(), maxDate.Month(), maxDate.Day(), 0, 0, 0, 0, maxDate.Location()) // truncate time to day - return &maxDate, nil + 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 } - maxDate = currentDate - currentDate = currentDate.Add(-1 * dateDiff / 2) } else { - minDate = currentDate - currentDate = currentDate.Add(maxDate.Sub(currentDate) / 2) + from = normalizedTime } + m[normalizedTime] = &ticker + to = normalizedTime } + return m, from, to } -// syncLatest downloads the latest FiatRates data every rd.PeriodSeconds -func (rd *RatesDownloader) syncLatest() error { - timer := time.NewTimer(rd.periodSeconds) - var lastTickerRates map[string]float64 - sameTickerCounter := 0 - for { - ticker, err := rd.downloader.getTicker(nil) - if err != nil { - // Do not exit on GET error, log it, wait and try again - glog.Errorf("syncLatest GetData error: %v", err) - <-timer.C - timer.Reset(rd.periodSeconds) - continue - } +// 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) +} - if sameTickerCounter < 5 && reflect.DeepEqual(ticker.Rates, lastTickerRates) { - // If rates are the same as previous, do not store them - glog.Infof("syncLatest: ticker rates for %v are the same as previous, skipping...", ticker.Timestamp) - <-timer.C - timer.Reset(rd.periodSeconds) - sameTickerCounter++ - continue +// 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 + } + glog.Error(message, err) +} + +const historicalPollInterval = time.Minute + +const historicalBanBackoff = 30 * time.Minute + +// ReconcileHistoricalRatesAtStartup runs the blocking startup self-healing pass that repairs +// missing historical fiat rates (interior holes and trailing gaps) within the reconcile +// window. It is meant to run once, before the periodic downloader loops start, so the DB is +// consistent and there is no concurrent Free-tier throttling. Honors the per-coin config +// toggle and is a no-op when fiat rates are disabled. The stop channel (blockbook's +// chanOsSignal, closed on shutdown) lets a SIGTERM mid-repair abort the pass promptly so +// shutdown is not delayed by a long backfill. +func (fr *FiatRates) ReconcileHistoricalRatesAtStartup(stop <-chan os.Signal) { + if !fr.Enabled || fr.downloader == nil { + return + } + if !fr.reconcileAtStartup { + glog.Info("FiatRatesDownloader: startup historical reconciliation disabled by config") + return + } + // A fiat-rate bug must never brick startup; recover, log and let blockbook come up. + defer func() { + if r := recover(); r != nil { + glog.Errorf("FiatRatesDownloader: reconciliation panic recovered, continuing startup: %v", r) + } + }() + start := time.Now() + // Bound the pass by both a wall-clock budget and the shutdown signal so a slow/throttling + // CDN can never stall startup and a SIGTERM aborts the repair promptly. + ctx, cancel := context.WithTimeout(context.Background(), fr.reconcileMaxDuration) + defer cancel() + if stop != nil { + go func() { + select { + case <-stop: + cancel() + case <-ctx.Done(): + } + }() + } + glog.Infof("FiatRatesDownloader: starting historical rates reconciliation (startup self-healing), budget %v", fr.reconcileMaxDuration) + filled, err := fr.downloader.ReconcileHistoricalRates(ctx, reconcileWindowDays, reconcileMaxGapDays) + if err != nil { + fr.observeUpdateDuration("reconcile", "error", start) + logFiatRatesDownloaderError("FiatRatesDownloader: reconciliation error ", err) + return + } + fr.observeUpdateDuration("reconcile", "success", start) + // only refresh the in-memory daily cache (a full-history scan) if anything was repaired + if filled > 0 { + if err := fr.loadDailyTickers(); err != nil { + glog.Error("FiatRatesDownloader: loadDailyTickers after reconciliation error ", err) } - lastTickerRates = ticker.Rates - sameTickerCounter = 0 + } + glog.Infof("FiatRatesDownloader: historical rates reconciliation finished in %v (%d points filled)", time.Since(start), filled) +} - glog.Infof("syncLatest: storing ticker for %v", ticker.Timestamp) - err = rd.db.FiatRatesStoreTicker(ticker) - if err != nil { - // If there's an error storing ticker (like missing rates), log it, wait and try again - glog.Errorf("syncLatest StoreTicker error: %v", err) - } else if rd.callbackOnNewTicker != nil { - rd.callbackOnNewTicker(ticker) +func (fr *FiatRates) RunDownloader() error { + glog.Infof("Starting %v FiatRates downloader...", fr.provider) + go fr.runHistoricalLoop() + fr.runCurrentLoop() + return nil +} + +func (fr *FiatRates) runCurrentLoop() { + tickerFromIs := fr.GetCurrentTicker("", "") + firstRun := true + for { + unix := time.Now().Unix() + 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() < 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(3)) + time.Sleep(time.Duration(next-unix) * time.Second) } - <-timer.C - timer.Reset(rd.periodSeconds) + firstRun = false + + fr.updateCurrentTickers() + fr.updateHourlyTickersIfDue() + fr.updateFiveMinutesTickersIfDue() } } -// syncHistorical downloads all the historical data since the specified timestamp till today, -// then continues to download the latest rates -func (rd *RatesDownloader) syncHistorical(timestamp *time.Time) error { - period := time.Duration(1) * time.Second - timer := time.NewTimer(period) +func (fr *FiatRates) updateCurrentTickers() { + start := time.Now() + currentTicker, err := fr.downloader.CurrentTickers() + if err != nil || currentTicker == nil { + fr.observeUpdateDuration("current_tickers", "error", start) + logFiatRatesDownloaderError("FiatRatesDownloader: CurrentTickers error ", err) + return + } + fr.setCurrentTicker(currentTicker) + fr.observeUpdateDuration("current_tickers", "success", start) + glog.Info("FiatRatesDownloader: CurrentTickers updated") + if fr.callbackOnNewTicker != nil { + fr.callbackOnNewTicker(currentTicker) + } +} + +func (fr *FiatRates) updateHourlyTickersIfDue() { + // it is necessary to wait about 1 hour to prepare the tickers + if time.Now().UTC().Unix() < fr.hourlyTickersTo+secondsInHour+secondsInHour { + return + } + start := time.Now() + hourlyTickers, err := fr.downloader.HourlyTickers() + if err != nil || hourlyTickers == nil { + fr.observeUpdateDuration("hourly_tickers", "error", start) + logFiatRatesDownloaderError("FiatRatesDownloader: HourlyTickers error ", err) + return + } + fr.setHourlyTickers(hourlyTickers) + fr.observeUpdateDuration("hourly_tickers", "success", start) + glog.Info("FiatRatesDownloader: HourlyTickers updated") +} + +func (fr *FiatRates) updateFiveMinutesTickersIfDue() { + // it is necessary to wait about 10 minutes to prepare the tickers + if time.Now().UTC().Unix() < fr.fiveMinutesTickersTo+3*secondsInFiveMinutes { + return + } + start := time.Now() + fiveMinutesTickers, err := fr.downloader.FiveMinutesTickers() + if err != nil || fiveMinutesTickers == nil { + fr.observeUpdateDuration("five_minutes_tickers", "error", start) + logFiatRatesDownloaderError("FiatRatesDownloader: FiveMinutesTickers error ", err) + return + } + fr.setFiveMinutesTickers(fiveMinutesTickers) + fr.observeUpdateDuration("five_minutes_tickers", "success", start) + glog.Info("FiatRatesDownloader: FiveMinutesTickers updated") +} + +// runHistoricalLoop updates historical (daily) tickers once a day, 1 hour after +// UTC midnight (to let the provider prepare historical rates). +func (fr *FiatRates) runHistoricalLoop() { + is := fr.db.GetInternalState() + var lastHistoricalTickers time.Time for { - if rd.startTime.Sub(*timestamp) < time.Duration(time.Hour*24) { - break + now := time.Now().UTC() + if (now.YearDay() != lastHistoricalTickers.YearDay() || now.Year() != lastHistoricalTickers.Year()) && now.Hour() > 0 { + done, banned := fr.runHistoricalCycle(is) + if done { + lastHistoricalTickers = time.Now().UTC() + } else if banned { + // Cloudflare IP ban: do not re-probe the banned endpoint every poll + // interval. Back off; the next attempt resumes from the gap. + time.Sleep(historicalBanBackoff) + continue + } + // non-ban failure falls through to the poll-interval retry } + time.Sleep(historicalPollInterval) + } +} - ticker, err := rd.downloader.getTicker(timestamp) - if err != nil { - // Do not exit on GET error, log it, wait and try again - glog.Errorf("syncHistorical GetData error: %v", err) - <-timer.C - timer.Reset(rd.periodSeconds) - continue +// runHistoricalCycle runs one daily historical update. +func (fr *FiatRates) runHistoricalCycle(is *common.InternalState) (done bool, banned bool) { + bootstrapInProgress, _, bootstrapErr := historicalBootstrapInProgress(fr.db) + if bootstrapErr != nil { + glog.Error("FiatRatesDownloader: bootstrap state check error ", bootstrapErr) + return false, false + } + + historicalTickersStart := time.Now() + err := fr.downloader.UpdateHistoricalTickers() + if err != nil { + fr.observeUpdateDuration("historical_tickers", "error", historicalTickersStart) + logFiatRatesDownloaderError("FiatRatesDownloader: UpdateHistoricalTickers error ", err) + ban := isCoingeckoCloudflareBanError(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) + // Advance the daily guard so we do not re-enter the historical block + // again in the same UTC day. + return true, ban + } else { + glog.Warningf("FiatRatesDownloader: bootstrap attempt %d/%d failed", attempts, maxHistoricalBootstrapAttempts) + } } + // Base historical pass failed; skip token/bootstrap-completion handling for this cycle. + return false, ban + } - glog.Infof("syncHistorical: storing ticker for %v", ticker.Timestamp) - err = rd.db.FiatRatesStoreTicker(ticker) - if err != nil { - // If there's an error storing ticker (like missing rates), log it and continue to the next day - glog.Errorf("syncHistorical error storing ticker for %v: %v", timestamp, err) + 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 { + fr.observeUpdateDuration("load_daily_tickers", "success", loadDailyTickersStart) + fr.mux.RLock() + ticker, found := fr.dailyTickers[fr.dailyTickersTo] + fr.mux.RUnlock() + 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 + } + } + } + + cycleSuccessful := true + if fr.downloadTokens { + historicalTokenTickersStart := time.Now() + tokErr := fr.downloader.UpdateHistoricalTokenTickers() + if tokErr != nil { + banned = banned || isCoingeckoCloudflareBanError(tokErr) + if bootstrapInProgress { + cycleSuccessful = false + } + if isCoingeckoHistoricalTokenUpdateInProgressError(tokErr) { + 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 ", tokErr) + } + } else { + fr.observeUpdateDuration("historical_token_tickers", "success", historicalTokenTickersStart) + glog.Info("FiatRatesDownloader: UpdateHistoricalTokenTickers finished") + if is != nil { + is.HistoricalTokenFiatRatesTime = time.Now().UTC() + } } + } - *timestamp = timestamp.Add(time.Hour * 24) // go to the next day + 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) + } + } - <-timer.C - timer.Reset(period) + 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) + } } - return nil + + return cycleSuccessful, banned } diff --git a/fiat/fiat_rates_test.go b/fiat/fiat_rates_test.go index 541da72310..f7643607b7 100644 --- a/fiat/fiat_rates_test.go +++ b/fiat/fiat_rates_test.go @@ -3,21 +3,23 @@ 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/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/common" - "github.com/syscoin/blockbook/db" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/btc" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" ) func TestMain(m *testing.M) { @@ -30,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, 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) } @@ -66,19 +73,15 @@ func bitcoinTestnetParser() *btc.BitcoinParser { } // getFiatRatesMockData reads a stub JSON response from a file and returns its content as string -func getFiatRatesMockData(dateParam string) (string, error) { +func getFiatRatesMockData(name string) (string, error) { var filename string - if dateParam == "current" { - filename = "fiat/mock_data/current.json" - } else { - filename = "fiat/mock_data/" + dateParam + ".json" - } + filename = "fiat/mock_data/" + name + ".json" mockFile, err := os.Open(filename) if err != nil { 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 @@ -87,90 +90,920 @@ func getFiatRatesMockData(dateParam string) (string, error) { } func TestFiatRates(t *testing.T) { - d, _, tmp := setupRocksDB(t, &testBitcoinParser{ - BitcoinParser: bitcoinTestnetParser(), - }) - defer closeAndDestroyRocksDB(t, d, tmp) - mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var err error var mockData string if r.URL.Path == "/ping" { w.WriteHeader(200) - } else if r.URL.Path == "/coins/bitcoin/history" { - date := r.URL.Query()["date"][0] - mockData, err = getFiatRatesMockData(date) // get stub rates by date - } else if r.URL.Path == "/coins/bitcoin" { - mockData, err = getFiatRatesMockData("current") // get "latest" stub rates + } else if r.URL.Path == "/coins/list" { + mockData, err = getFiatRatesMockData("coinlist") + } else if r.URL.Path == "/simple/supported_vs_currencies" { + mockData, err = getFiatRatesMockData("vs_currencies") + } else if r.URL.Path == "/simple/price" { + if r.URL.Query().Get("ids") == "ethereum" { + mockData, err = getFiatRatesMockData("simpleprice_base") + } else { + mockData, err = getFiatRatesMockData("simpleprice_tokens") + } + } else if r.URL.Path == "/coins/ethereum/market_chart" { + vsCurrency := r.URL.Query().Get("vs_currency") + if vsCurrency == "usd" { + days := r.URL.Query().Get("days") + if days == "max" { + mockData, err = getFiatRatesMockData("market_chart_eth_usd_max") + } else { + mockData, err = getFiatRatesMockData("market_chart_eth_usd_1") + } + } else { + mockData, err = getFiatRatesMockData("market_chart_eth_other") + } + } else if r.URL.Path == "/coins/vendit/market_chart" || r.URL.Path == "/coins/ethereum-cash-token/market_chart" { + mockData, err = getFiatRatesMockData("market_chart_token_other") } else { - t.Errorf("Unknown URL path: %v", r.URL.Path) + t.Fatalf("Unknown URL path: %v", r.URL.Path) } if err != nil { - t.Errorf("Error loading stub data: %v", err) + t.Fatalf("Error loading stub data: %v", err) } fmt.Fprintln(w, mockData) })) defer mockServer.Close() - // real CoinGecko API - //configJSON := `{"fiat_rates": "coingecko", "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcoin\", \"periodSeconds\": 60}"}` + // config with mocked CoinGecko API + config := common.Config{ + CoinName: "fakecoin", + FiatRates: "coingecko", + FiatRatesParams: `{"url": "` + mockServer.URL + `", "coin": "ethereum","platformIdentifier": "ethereum","platformVsCurrency": "eth","periodSeconds": 60}`, + } - // mocked CoinGecko API - configJSON := `{"fiat_rates": "coingecko", "fiat_rates_params": "{\"url\": \"` + mockServer.URL + `\", \"coin\": \"bitcoin\", \"periodSeconds\": 60}"}` + d, _, tmp := setupRocksDB(t, &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + }, &config) + defer closeAndDestroyRocksDB(t, d, tmp) - type fiatRatesConfig struct { - FiatRates string `json:"fiat_rates"` - FiatRatesParams string `json:"fiat_rates_params"` + fiatRates, err := NewFiatRates(d, &config, nil, nil) + if err != nil { + 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 - var config fiatRatesConfig - err := json.Unmarshal([]byte(configJSON), &config) + // get current tickers + currentTickers, err := fiatRates.downloader.CurrentTickers() if err != nil { - t.Errorf("Error parsing config: %v", err) + t.Fatalf("Error in CurrentTickers: %v", err) + return } - - if config.FiatRates == "" || config.FiatRatesParams == "" { - t.Errorf("Error parsing FiatRates config - empty parameter") + if currentTickers == nil { + t.Fatalf("CurrentTickers returned nil value") return } - testStartTime := time.Date(2019, 11, 22, 16, 0, 0, 0, time.UTC) - fiatRates, err := NewFiatRatesDownloader(d, config.FiatRates, config.FiatRatesParams, &testStartTime, 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("FiatRatesFindLastTicker failed with error: %v", err) + } + if ticker != nil { + t.Fatalf("FiatRatesFindLastTicker found unexpected data") + } + + // 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.Errorf("FiatRates init error: %v\n", err) + 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) } - if config.FiatRates == "coingecko" { - timestamp, err := fiatRates.findEarliestMarketData() + 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) { + 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 err != nil { - t.Errorf("Error looking up earliest market data: %v", err) - return + 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)) + } +} + +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}, + }, + }, + hourlyTickersFrom: 3600, + hourlyTickersTo: 3600, + dailyTickers: map[int64]*common.CurrencyRatesTicker{ + 86400: { + Timestamp: time.Unix(86400, 0).UTC(), + Rates: map[string]float32{"usd": 3}, + }, + }, + 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)}, + } + 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)}, + }, } - earliestTimestamp, _ := time.Parse(db.FiatRatesTimeFormat, "20130429000000") - if *timestamp != earliestTimestamp { - t.Errorf("Incorrect earliest available timestamp found. Wanted: %v, got: %v", earliestTimestamp, timestamp) - return + 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)}, + }, } + 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() + } + + // 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: + } - // After verifying that findEarliestMarketData works correctly, - // set the earliest available timestamp to 2 days ago for easier testing - *timestamp = fiatRates.startTime.Add(time.Duration(-24*2) * time.Hour) + setState(counter) - err = fiatRates.syncHistorical(timestamp) + 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.Errorf("RatesDownloader syncHistorical error: %v", err) - return + t.Fatal(err) + } + } + + totalCalls := 0 + for i, calls := range readerCalls { + if calls == 0 { + t.Fatalf("reader %d did not make any successful calls", i) + } + totalCalls += calls + } + if totalCalls < readers { + t.Fatalf("too few reader calls made: got %d", totalCalls) + } +} + +// TestLogTickersInfo_ConcurrentWithCacheWriters reproduces the data race between the historical +// goroutine (which calls logTickersInfo after a daily cycle) and the current goroutine (which +// replaces the hourly/5-minute ticker maps and their bounds under fr.mux). Before logTickersInfo +// took the read lock this failed under -race. It mirrors the production split introduced by +// RunDownloader starting runHistoricalLoop and runCurrentLoop concurrently. +func TestLogTickersInfo_ConcurrentWithCacheWriters(t *testing.T) { + fr := &FiatRates{Enabled: true, provider: "coingecko"} + + const ( + writers = 2 + loggers = 4 + testDuration = 300 * time.Millisecond + waitTimeout = 3 * time.Second + ) + + stop := make(chan struct{}) + var wg sync.WaitGroup + + // writeCacheState mimics the locked sections of setHourlyTickers/setFiveMinutesTickers/ + // setCurrentTicker: it replaces the map headers and the int64 bounds under fr.mux. + writeCacheState := func(counter int64) { + fr.mux.Lock() + fr.hourlyTickers = map[int64]*common.CurrencyRatesTicker{ + 3600 + counter: {Timestamp: time.Unix(3600+counter, 0).UTC()}, + } + fr.hourlyTickersFrom = 3600 + counter + fr.hourlyTickersTo = 7200 + counter + fr.fiveMinutesTickers = map[int64]*common.CurrencyRatesTicker{ + 600 + counter: {Timestamp: time.Unix(600+counter, 0).UTC()}, + } + fr.fiveMinutesTickersFrom = 600 + counter + fr.fiveMinutesTickersTo = 900 + counter + fr.dailyTickers = map[int64]*common.CurrencyRatesTicker{ + 86400 + counter: {Timestamp: time.Unix(86400+counter, 0).UTC()}, + } + fr.dailyTickersFrom = 86400 + counter + fr.dailyTickersTo = 172800 + counter + fr.mux.Unlock() + } + + writeCacheState(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: + } + writeCacheState(counter) + counter++ + } + }(w) + } + + for l := 0; l < loggers; l++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + fr.logTickersInfo() + } + }() + } + + 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 logTickersInfo/writers did not finish in time") + } +} + +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}, + } + } + 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) + + // 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 + } + _ = 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 + } + _ = 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 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 } - ticker, err := fiatRates.downloader.getTicker(fiatRates.startTime) + _ = 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 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 { - // Do not exit on GET error, log it, wait and try again - glog.Errorf("Sync GetData error: %v", err) - return + t.Fatalf("registerHistoricalBootstrapAttemptFailure failed: %v", err) + } + if exhausted { + t.Fatalf("attempt %d unexpectedly exhausted", i) } - err = fiatRates.db.FiatRatesStoreTicker(ticker) + if attempts != i { + t.Fatalf("unexpected attempts value: got %d, want %d", attempts, i) + } + complete, found, err := d.FiatRatesGetHistoricalBootstrapComplete() if err != nil { - glog.Errorf("Sync StoreTicker error %v", err) - return + t.Fatalf("FiatRatesGetHistoricalBootstrapComplete failed: %v", err) + } + 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/01-02-2013.json b/fiat/mock_data/01-02-2013.json deleted file mode 100644 index 94ba1bd575..0000000000 --- a/fiat/mock_data/01-02-2013.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}} diff --git a/fiat/mock_data/01-05-2013.json b/fiat/mock_data/01-05-2013.json deleted file mode 100644 index 060f0e0856..0000000000 --- a/fiat/mock_data/01-05-2013.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":112.481,"brl":232.8687,"btc":1.0,"cad":117.617,"chf":108.7145,"cny":718.7368,"dkk":661.3731,"eur":88.6291,"gbp":74.9767,"hkd":903.2559,"idr":1130568.3956,"inr":6274.6092,"jpy":11364.3607,"krw":128625.969,"mxn":1412.9046,"myr":353.6681,"nzd":136.2101,"php":4792.6186,"pln":368.8928,"rub":3623.3519,"sek":758.5144,"sgd":143.534,"twd":3433.0342,"usd":117.0,"xag":4.9088,"xau":0.0808,"xdr":76.8864,"zar":1049.0856},"market_cap":{"aud":1248780934.15,"brl":2585343237.705,"btc":11102150.0,"cad":1305801576.55,"chf":1206964686.175,"cny":7979523764.12,"dkk":7342663362.165,"eur":983973562.5649999,"gbp":832402569.9049999,"hkd":10028082490.185,"idr":12551739913210.54,"inr":69661652529.78,"jpy":126168837145.505,"krw":1428024801733.35,"mxn":15686278804.89,"myr":3926476296.415,"nzd":1512224961.715,"php":53208370589.99,"pln":4095503199.52,"rub":40226996296.585,"sek":8421140645.96,"sgd":1593535998.1,"twd":38114060643.53,"usd":1298951550.0,"xag":54498233.92,"xau":897053.72,"xdr":853604345.7599999,"zar":11647105694.04},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"usd":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}} \ No newline at end of file diff --git a/fiat/mock_data/04-04-2013.json b/fiat/mock_data/04-04-2013.json deleted file mode 100644 index 94ba1bd575..0000000000 --- a/fiat/mock_data/04-04-2013.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}} diff --git a/fiat/mock_data/05-05-2013.json b/fiat/mock_data/05-05-2013.json deleted file mode 100644 index 1cd65273ca..0000000000 --- a/fiat/mock_data/05-05-2013.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":112.4208,"brl":233.1081,"btc":1.0,"cad":117.0104,"chf":108.4737,"cny":715.1351,"dkk":659.3659,"eur":88.4403,"gbp":74.4895,"hkd":899.8427,"idr":1128375.8759,"inr":6235.1253,"jpy":11470.4956,"krw":127344.157,"mxn":1401.3929,"myr":352.0832,"nzd":135.8774,"php":4740.2255,"pln":366.461,"rub":3602.9548,"sek":754.5925,"sgd":143.0844,"twd":3426.0044,"usd":116.79,"xag":4.9089,"xau":0.0807,"xdr":76.8136,"zar":1033.9804},"market_cap":{"aud":1249804517.76,"brl":2591509369.32,"btc":11117200.0,"cad":1300828018.88,"chf":1205923817.64,"cny":7950299933.72,"dkk":7330302583.48,"eur":983208503.1599998,"gbp":828114669.4000001,"hkd":10003731264.44,"idr":12544380287555.48,"inr":69317134985.16,"jpy":127519793684.32,"krw":1415710462200.4,"mxn":15579565147.88,"myr":3914179351.04,"nzd":1510576231.28,"php":52698034928.6,"pln":4074020229.2,"rub":40054769102.56,"sek":8388955741.0,"sgd":1590697891.68,"twd":38087576115.68,"usd":1298377788.0,"xag":54573223.08,"xau":897158.0399999999,"xdr":853952153.9199998,"zar":11494966902.88},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"usd":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}} \ No newline at end of file diff --git a/fiat/mock_data/05-06-2013.json b/fiat/mock_data/05-06-2013.json deleted file mode 100644 index d45b810ae0..0000000000 --- a/fiat/mock_data/05-06-2013.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":126.3133,"brl":259.5964,"btc":1.0,"cad":126.0278,"chf":115.6713,"cny":748.4143,"dkk":695.3988,"eur":93.2043,"gbp":79.6307,"hkd":946.0816,"idr":1196029.7068,"inr":6892.8316,"jpy":12203.5904,"krw":137012.0733,"mxn":1551.7041,"myr":377.299,"nzd":152.0791,"php":5112.2715,"pln":396.1512,"rub":3891.7674,"sek":800.3999,"sgd":152.8465,"twd":3646.9125,"uah":987.805115156,"usd":121.309,"xag":5.3382,"xau":0.0868,"xdr":81.033,"zar":1200.5467},"market_cap":{"aud":1420386743.3035636,"brl":2919148539.142982,"btc":11244950.003709536,"cad":1417176310.0775046,"chf":1300717985.3640869,"cny":8415881385.561269,"dkk":7819724738.639607,"eur":1048077693.6307446,"gbp":895443240.2603929,"hkd":10638640291.429523,"idr":13449294255917.375,"inr":77509546725.9892,"jpy":137228763913.74965,"krw":1540693914163.0862,"mxn":17448835025.0511,"myr":4242708391.449604,"nzd":1710121876.1091428,"php":57487237422.88915,"pln":4454700437.909536,"rub":43762729839.06665,"sek":9000456858.474112,"sgd":1718751250.7419894,"twd":41009348730.40335,"uah":11107819133.33776,"usd":1364113640.0,"xag":60027792.10980224,"xau":976061.6603219877,"xdr":911212033.6505947,"zar":13500087618.61847},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"uah":0.0,"usd":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}} \ No newline at end of file diff --git a/fiat/mock_data/07-10-2013.json b/fiat/mock_data/07-10-2013.json deleted file mode 100644 index 328a45ddaf..0000000000 --- a/fiat/mock_data/07-10-2013.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":130.8239,"bdt":9961.3816372,"bhd":48.39643563999999,"bmd":128.38,"brl":272.2555,"btc":1.0,"cad":127.0763,"chf":111.3766,"cny":754.702,"dkk":677.221,"eur":90.7575,"gbp":76.6108,"hkd":955.6959,"idr":1401976.6268,"inr":7601.0098,"jpy":11938.215,"krw":132238.2292,"ltc":59.84440454817475,"mmk":124564.42058759999,"mxn":1619.1347,"myr":393.0957,"nzd":148.4503,"php":5315.0149,"pln":381.3587,"rub":3973.0086,"sek":791.16,"sgd":153.7814,"twd":3623.6843,"uah":1051.00353918,"usd":128.38,"vef":807.6801751200001,"xag":5.5156,"xau":0.0932,"xdr":80.1381,"zar":1233.5253},"market_cap":{"aud":1544578916.545,"bdt":117609550368.68365,"bhd":571394937.205442,"bmd":1515724889.0,"brl":3214398173.525,"btc":11806550.0,"cad":1500332689.765,"chf":1314973396.73,"cny":8910426898.1,"dkk":7995643597.55,"eur":1071532961.6249999,"gbp":904509240.74,"hkd":11283471428.145,"idr":16552507143145.54,"inr":89741702254.19,"jpy":140949132308.25,"krw":1561277264961.26,"ltc":706555954.5182526,"mmk":1470676059888.5288,"mxn":19116394792.285,"myr":4641104036.835,"nzd":1752685889.465,"php":62751989167.595,"pln":4502530559.485,"rub":46907524686.33,"sek":9340870098.0,"sgd":1815627788.17,"twd":42783209872.165,"uah":12408725835.50563,"usd":1515724889.0,"vef":9535916371.563036,"xag":65120207.18,"xau":1100370.4600000002,"xdr":946154484.5549998,"zar":14563678130.715},"total_volume":{"aud":0.0,"bdt":0.0,"bhd":0.0,"bmd":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"ltc":0.0,"mmk":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"uah":0.0,"usd":0.0,"vef":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}} \ No newline at end of file diff --git a/fiat/mock_data/13-06-2014.json b/fiat/mock_data/13-06-2014.json deleted file mode 100644 index c784ee4862..0000000000 --- a/fiat/mock_data/13-06-2014.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":635.4009,"bdt":46187.0412867627,"bhd":224.2182020654,"bmd":594.6833,"brl":1330.3026,"btc":1.0,"cad":645.6162,"chf":537.5144,"cny":3818.09,"dkk":3291.0056,"eur":439.2353,"gbp":350.5075,"hkd":4609.773,"idr":7056654.8237,"inr":35623.7845,"jpy":60664.7779,"krw":605273.662,"ltc":58.68544600938967,"mmk":576316.9820261401,"mxn":7774.4393,"myr":1921.5065,"nzd":689.3958,"php":26182.8705,"pln":1816.6144,"rub":20449.5057,"sek":3957.6568,"sgd":743.7241,"twd":17936.3326,"uah":6989.384186896,"usd":594.6833,"vef":3749.9319498579002,"vnd":12616057.538675,"xag":30.3811,"xau":0.4678,"xdr":388.0442,"zar":6383.9883},"market_cap":{"aud":8191238932.305,"bdt":595417933396.2369,"bhd":2890497741.0160007,"bmd":7666330027.785,"brl":17149529452.77,"btc":12891450.0,"cad":8322928961.49,"chf":6929340011.88,"cny":49220716330.5,"dkk":42425834142.12,"eur":5662379908.185,"gbp":4518549910.875,"hkd":59426658140.85,"idr":90970512826987.36,"inr":459242236692.525,"jpy":782056951058.955,"krw":7802855149989.9,"ltc":756540492.9577465,"mmk":7429561557940.883,"mxn":100223795513.985,"myr":24771004969.425,"nzd":8887311485.91,"php":337535165907.225,"pln":23418793706.88,"rub":263623780256.265,"sek":51019934754.36,"sgd":9587682048.945,"twd":231225334896.27,"uah":90103296776.16043,"usd":7666330027.785,"vef":48342060234.99562,"vnd":162639274956951.8,"xag":391656431.595,"xau":6030620.31,"xdr":5002452402.09,"zar":82298865970.035},"total_volume":{"aud":40549103.56573137,"bdt":2947498375.4849124,"bhd":14308835.723827252,"bmd":37950646.151919045,"brl":84895343.87055749,"btc":63816.5661486022,"cad":41201008.933909185,"chf":34302323.26342622,"cny":243657393.04631656,"dkk":210020676.56782028,"eur":28030488.577251133,"gbp":22368185.059331186,"hkd":294179883.5845404,"idr":450331479344.50385,"inr":2273387600.0077996,"jpy":3871417811.7456107,"krw":38626486689.029686,"ltc":3745103.647218439,"mmk":36778570806.03395,"mxn":496138019.85674256,"myr":122623946.66221909,"nzd":43994872.673268534,"php":1670900887.223535,"pln":115930093.0241033,"rub":1305017233.2102678,"sek":252564066.9706653,"sgd":47461918.22395964,"twd":1144635155.8312302,"uah":446038498.30104274,"usd":37950646.151919045,"vef":239307780.33086348,"vnd":805113470451.4246,"xag":1938817.4778172984,"xau":29853.38964431611,"xdr":24763648.357881423,"zar":407404211.6388525}},"community_data":{"facebook_likes":22450,"twitter_followers":54747,"reddit_average_posts_48h":2.449,"reddit_average_comments_48h":266.163,"reddit_subscribers":122886,"reddit_accounts_active_48h":"957.0"},"developer_data":{"forks":3894,"stars":5469,"subscribers":757,"total_issues":4332,"closed_issues":3943,"pull_requests_merged":1950,"pull_request_contributors":201,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}} diff --git a/fiat/mock_data/20-04-2013.json b/fiat/mock_data/20-04-2013.json deleted file mode 100644 index 94ba1bd575..0000000000 --- a/fiat/mock_data/20-04-2013.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}} diff --git a/fiat/mock_data/20-11-2019.json b/fiat/mock_data/20-11-2019.json deleted file mode 100644 index 2dcf90600b..0000000000 --- a/fiat/mock_data/20-11-2019.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aed":29895.556252804836,"ars":485421.07790578756,"aud":11927.653049612183,"bch":33.63953944857689,"bdt":690035.4308521386,"bhd":3068.738317969027,"bmd":8138.831605359057,"bnb":440.3065882935356,"brl":34128.562570752125,"btc":1.0,"cad":10801.69453000047,"chf":8061.919646688408,"clp":6426418.993942025,"cny":57197.26687298181,"czk":187745.75358926164,"dkk":54903.744126591664,"eos":2611.7345692770737,"eth":46.298470465960534,"eur":7346.923290157612,"gbp":6295.369969082021,"hkd":63709.552982009845,"huf":2444677.126964913,"idr":114676137.3195094,"ils":28177.44890091363,"inr":584864.5454373802,"jpy":882985.9102812062,"krw":9504690.325370407,"kwd":2470.900442397376,"lkr":1459699.318199838,"ltc":147.7002152423325,"mmk":12336753.62587893,"mxn":157572.5004020829,"myr":33838.006282440794,"nok":74323.81022013885,"nzd":12659.561898218928,"php":414372.5288556039,"pkr":1268614.8449705977,"pln":31481.39945227751,"rub":519856.47442806256,"sar":30523.100863736137,"sek":78433.10629768472,"sgd":11082.158667121108,"thb":245711.32616578983,"try":46424.77447078138,"twd":247819.28355157803,"uah":196944.8714820098,"usd":8138.831605359057,"vef":2022399076.2122076,"vnd":188819978.95240378,"xag":474.4301555409632,"xau":5.522685574132437,"xdr":5913.04021558867,"xlm":124534.24474263463,"xrp":32009.044210117067,"zar":120220.54543519647},"market_cap":{"aed":539216184347.0786,"ars":8754702929895.195,"aud":215108532402.1839,"bch":606965693.7308347,"bdt":12445939086798.977,"bhd":55349810272.20994,"bmd":146797393103.30997,"bnb":7956861576.178785,"brl":615565508500.11,"btc":18056975.0,"cad":194793828360.1895,"chf":145401203097.50433,"clp":115881862115752.81,"cny":1031662719251.4414,"czk":3386836054983.015,"dkk":990235760930.7228,"eos":47156656992.1783,"eth":836285546.5188296,"eur":132513272767.39243,"gbp":113531342257.38261,"hkd":1149073035824.1863,"huf":44093634990629.44,"idr":2068029594287882.2,"ils":508227254662.9701,"inr":10549007465796.953,"jpy":15924140811667.746,"krw":171432931613907.47,"kwd":44566807761.80631,"lkr":26328110104320.3,"ltc":2665915975.9376416,"mmk":222513913476766.62,"mxn":2842848955360.077,"myr":610324841566.3215,"nok":1340509754601.4958,"nzd":228301948107.3441,"php":7486962257826.343,"pkr":22881583146556.33,"pln":567829491818.596,"rub":9376404569427.018,"sar":550534997342.308,"sek":1414830338781.8389,"sgd":199869788618.91415,"thb":4430345323857.896,"try":837402059023.0033,"twd":4471888986106.136,"uah":3552229007857.6865,"usd":146797393103.30997,"vef":36477338099366760,"vnd":3405959412743900,"xag":8552645126.132073,"xau":99600563.24666482,"xdr":106651535632.2029,"xlm":2242611242035.2026,"xrp":577654290936.0109,"zar":2168300254311.0623},"total_volume":{"aed":91806191763.19203,"ars":1490678420139.214,"aud":36628601050.19057,"bch":103303580.75045899,"bdt":2119028144266.9175,"bhd":9423781116.770079,"bmd":24993518393.55118,"bnb":1352136442.5417123,"brl":104805320679.67813,"btc":3074333.834646516,"cad":33170897741.553368,"chf":24757329644.7321,"clp":19734874625492.48,"cny":175646949214.35953,"czk":576547982950.5977,"dkk":168603775731.05692,"eos":8020369404.536936,"eth":142177861.5523058,"eur":22561649053.858624,"gbp":19332436490.375057,"hkd":195645512956.95944,"huf":7507353106907.763,"idr":352158674165137.06,"ils":86530060030.31366,"inr":1796059125304.906,"jpy":2711559307275.5625,"krw":29187930650356.914,"kwd":7587882223.171772,"lkr":4482587123987.1,"ltc":453572235.5970587,"mmk":37884907025485.92,"mxn":483889012339.82043,"myr":103913052073.02832,"nok":228240809969.9092,"nzd":38876218172.28588,"php":1272495601815.3118,"pkr":3895786274927.592,"pln":96676153828.6573,"rub":1596425996462.3315,"sar":93733316998.92708,"sek":240860037406.81345,"sgd":34032174385.395035,"thb":754554320301.3098,"try":142565728216.80225,"twd":761027641565.2402,"uah":604797531952.8716,"usd":24993518393.55118,"vef":6210580456920606,"vnd":579846819033510.8,"xag":1456926423.0950127,"xau":16959601.841128074,"xdr":18158340970.319584,"xlm":382431912530.61053,"xrp":98296496845.90811,"zar":369184983706.85724}},"community_data":{"facebook_likes":null,"twitter_followers":68549,"reddit_average_posts_48h":6.429,"reddit_average_comments_48h":227.357,"reddit_subscribers":1190720,"reddit_accounts_active_48h":"3557.33333333333"},"developer_data":{"forks":24603,"stars":41204,"subscribers":3495,"total_issues":0,"closed_issues":0,"pull_requests_merged":6973,"pull_request_contributors":623,"code_additions_deletions_4_weeks":{"additions":3441,"deletions":-1615},"commit_count_4_weeks":375},"public_interest_stats":{"alexa_rank":12740,"bing_matches":135000000}} diff --git a/fiat/mock_data/21-11-2019.json b/fiat/mock_data/21-11-2019.json deleted file mode 100644 index 14dd021d84..0000000000 --- a/fiat/mock_data/21-11-2019.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aed":29737.733661544353,"ars":482992.0576847001,"aud":11909.083120440264,"bch":33.38939623106484,"bdt":686435.825992477,"bhd":3052.4408925589955,"bmd":8095.865638011639,"bnb":447.42498131132714,"brl":33973.49056335204,"btc":1.0,"cad":10770.529152304092,"chf":8017.651480082802,"clp":6417583.259568359,"cny":56962.51062904988,"czk":186532.80032847245,"dkk":54611.02597516125,"eos":2625.1996127062725,"eth":46.30756277128346,"eur":7307.587392569718,"gbp":6263.7712441296035,"hkd":63361.07803605232,"huf":2437260.3503234047,"idr":113658578.86366026,"ils":28110.059875022114,"inr":581104.954710677,"jpy":878241.5283779115,"krw":9474186.762883117,"kwd":2457.953382894158,"lkr":1451476.6944985148,"ltc":147.17764652439055,"mmk":12276458.59050864,"mxn":157745.51319696513,"myr":33723.328315137434,"nok":73919.30120786528,"nzd":12630.060434833318,"php":412223.4002195826,"pkr":1261966.2105007921,"pln":31386.456698725444,"rub":516701.6230282524,"sar":30360.961494224146,"sek":78027.18391192055,"sgd":11029.362072616914,"thb":244420.8060296636,"try":46147.243723230094,"twd":246632.45889225203,"uah":195582.93374510246,"usd":8095.865638011639,"vef":2011722564.2894442,"vnd":187451397.8769113,"xag":471.370523519991,"xau":5.491344703606915,"xdr":5886.050536922535,"xlm":126226.86768614998,"xrp":32338.763084294602,"zar":119758.9020368509},"market_cap":{"aed":536925789662.0078,"ars":8721126622799.587,"aud":214993230987.64774,"bch":603145132.8718755,"bdt":12393852945152.863,"bhd":55112950276.81427,"bmd":146173851045.95633,"bnb":8069562575.040651,"brl":613403948529.2512,"btc":18058775.0,"cad":194516467063.8744,"chf":144785199461.0198,"clp":115857415095711.94,"cny":1028479215959.3489,"czk":3368167110571.1367,"dkk":986119641838.593,"eos":47362660343.0749,"eth":834881213.8173289,"eur":131953912642.35466,"gbp":113092515946.4908,"hkd":1143920014822.8933,"huf":44007835435061.79,"idr":2051934847845294.5,"ils":507537536909.2167,"inr":10491950179817.375,"jpy":15862731500313.05,"krw":171059949186530.47,"kwd":44379258220.658554,"lkr":26206949031136.875,"ltc":2660325666.65285,"mmk":221656004387641.53,"mxn":2848592157028.2905,"myr":608887176531.9319,"nok":1334790467520.1284,"nzd":228006504250.86572,"php":7443249821227.297,"pkr":22785267089002.48,"pln":566719821025.2997,"rub":9329253695306.072,"sar":548178398889.3755,"sek":1408776800748.5935,"sgd":199092731818.5716,"thb":4413592261082.244,"try":833269884841.5155,"twd":4453040344437.87,"uah":3531322270240.796,"usd":146173851045.95633,"vef":36322395603696830,"vnd":3384688469578368,"xag":8512844964.182701,"xau":99193575.31978594,"xdr":106274821359.85637,"xlm":2281320453520.52,"xrp":583446527953.1724,"zar":2162554421914.3018},"total_volume":{"aed":86108153638.4696,"ars":1398544851555.271,"aud":34483769701.4642,"bch":96681855.22416703,"bdt":1987633699334.181,"bhd":8838603921.209757,"bmd":23442272034.865948,"bnb":1295557337.0497513,"brl":98373150367.11145,"btc":2896005.038733083,"cad":31186989216.112743,"chf":23215796244.73709,"clp":18582661731791.32,"cny":164939826037.3168,"czk":540121692261.5999,"dkk":158130900913.4299,"eos":7601490219.642489,"eth":134087512.35435177,"eur":21159744891.37511,"gbp":18137285873.37578,"hkd":183467425740.0729,"huf":7057295996096.396,"idr":329108145311632.75,"ils":81395084845.85982,"inr":1682639144253.6147,"jpy":2543023530910.265,"krw":27433318848801.867,"kwd":7117214443.4175005,"lkr":4202875028575.5767,"ltc":426165475.2614542,"mmk":35547536825741.08,"mxn":456765637917.7518,"myr":97648784161.23398,"nok":214039664814.34357,"nzd":36571421237.528984,"php":1193628145421.9192,"pkr":3654131198332.759,"pln":90882172338.37012,"rub":1496153783854.0442,"sar":87912763181.98567,"sek":225934390856.19467,"sgd":31936462095.3393,"thb":707741368511.1285,"try":133623294825.93925,"twd":714145398712.4277,"uah":566327128343.4884,"usd":23442272034.865948,"vef":5825114906715977,"vnd":542781570103440.4,"xag":1364893704.471942,"xau":15900658.698529225,"xdr":17043563229.317081,"xlm":365500701557.407,"xrp":93639657003.90553,"zar":346772153302.9578}},"community_data":{"facebook_likes":null,"twitter_followers":68537,"reddit_average_posts_48h":6.5,"reddit_average_comments_48h":248.25,"reddit_subscribers":1191683,"reddit_accounts_active_48h":"3672.76923076923"},"developer_data":{"forks":24612,"stars":41218,"subscribers":3495,"total_issues":0,"closed_issues":0,"pull_requests_merged":6978,"pull_request_contributors":623,"code_additions_deletions_4_weeks":{"additions":3491,"deletions":-1642},"commit_count_4_weeks":388},"public_interest_stats":{"alexa_rank":12740,"bing_matches":135000000}} diff --git a/fiat/mock_data/22-11-2019.json b/fiat/mock_data/22-11-2019.json deleted file mode 100644 index 19ad080829..0000000000 --- a/fiat/mock_data/22-11-2019.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aed":28035.679772629657,"ars":455975.92278356064,"aud":11242.023660019182,"bch":33.7483287902988,"bdt":647296.9519021783,"bhd":2877.7787238679057,"bmd":7632.494765498649,"bnb":454.38160859138037,"brl":32004.577050688924,"btc":1.0,"cad":10134.64789197728,"chf":7577.578965660877,"clp":6088438.929707239,"cny":53650.33220564306,"czk":176064.87038406474,"dkk":51541.93185162167,"eos":2702.457481762241,"eth":47.35816140456382,"eur":6897.386297149177,"gbp":5908.00889818188,"hkd":59688.78043936735,"huf":2307617.389787238,"idr":107442628.81392431,"ils":26414.69053433299,"inr":547635.3233044049,"jpy":828835.8421129878,"krw":8981385.56540522,"kwd":2317.3933256902314,"lkr":1371578.7340592865,"ltc":150.51551045178746,"mmk":11576409.193776581,"mxn":147997.88975040163,"myr":31823.686924746547,"nok":69791.7992730364,"nzd":11915.530263116301,"php":388189.40886026394,"pkr":1185305.8751410455,"pln":29644.4875492805,"rub":486228.079036091,"sar":28621.16844609101,"sek":73412.31132752451,"sgd":10402.327115898055,"thb":230471.15540126187,"try":43490.718423287806,"twd":232501.04791412465,"uah":184490.35082571974,"usd":7632.494765498649,"vef":1896580628.6955354,"vnd":177318106.98911732,"xag":446.02452345840845,"xau":5.21078050135358,"xdr":5544.7555748075,"xlm":125542.05293968241,"xrp":31338.87101002938,"zar":112082.42238187103},"market_cap":{"aed":505952466029.5935,"ars":8228063686842.731,"aud":202906746120.7657,"bch":610302833.79301,"bdt":11681596156197.934,"bhd":51934508235.10716,"bmd":137741605692.4732,"bnb":8208811080.573547,"brl":577564326829.1097,"btc":18060475.0,"cad":182927050731.8614,"chf":136752620963.6013,"clp":109834974835741.86,"cny":968213294733.5328,"czk":3177872948714.952,"dkk":930200055102.5531,"eos":48834956976.25193,"eth":855479661.9732217,"eur":124481359054.06459,"gbp":106626741157.78348,"hkd":1077223378894.6129,"huf":41647543632679.81,"idr":1939126324938642.2,"ils":476698903812.6255,"inr":9882973293887.5,"jpy":14957705316159.902,"krw":162084679666504.06,"kwd":41821381803.56006,"lkr":24752517095323.91,"ltc":2718657573.4072695,"mmk":208916381798492.3,"mxn":2670451606202.2573,"myr":574313624934.7668,"nok":1259581556794.9636,"nzd":215062993789.54813,"php":7005804182301.389,"pkr":21390900288155.363,"pln":535015393864.2815,"rub":8774828990639.0205,"sar":516518624602.2619,"sek":1324936091931.084,"sgd":187713296046.46274,"thb":4159796491912.691,"try":784893129459.0262,"twd":4195747188740.0435,"uah":3329448357091.939,"usd":137741605692.4732,"vef":34227086837012150,"vnd":3200663218868120.5,"xag":8055064362.341162,"xau":94058232.86316237,"xdr":100064731062.59404,"xlm":2269248253100.4473,"xrp":566093764804.5127,"zar":2022682035850.9631},"total_volume":{"aed":96938279018.82988,"ars":1576616710817.6833,"aud":38871268152.918,"bch":116690764.74068807,"bdt":2238142718151.254,"bhd":9950424571.517105,"bmd":26390689050.100677,"bnb":1571104089.9267807,"brl":110661437324.88211,"btc":3458209.3682739506,"cad":35042322250.70604,"chf":26200808042.38517,"clp":21051845239481.676,"cny":185505431470.96753,"czk":608775163260.6022,"dkk":178215267527.76758,"eos":9344220633.428228,"eth":163749147.56075427,"eur":23848922615.61833,"gbp":20427976766.120914,"hkd":206384425112.9548,"huf":7978991778122.924,"idr":371501729758266.6,"ils":91333424478.36926,"inr":1893545161079.935,"jpy":2865845264861.2,"krw":31054715525924.953,"kwd":8012793790.76967,"lkr":4742473986606.729,"ltc":520433771.07914567,"mmk":40027464772158.91,"mxn":511728656025.9774,"myr":110035977994.39453,"nok":241317384348.23724,"nzd":41200035336.076935,"php":1342232952203.5798,"pkr":4098402912964.3467,"pln":102501014019.56622,"rub":1681218845936.662,"sar":98962708775.86293,"sek":253835939652.59772,"sgd":35967870106.38203,"thb":796894434137.848,"try":150376785276.3785,"twd":803913143453.4763,"uah":637907739340.2526,"usd":26390689050.100677,"vef":6557760099174900,"vnd":613108448584249.1,"xag":1542208002.620382,"xau":18017187.321394224,"xdr":19171964702.159466,"xlm":434083662502.9747,"xrp":108359641954.22104,"zar":387544629631.8232}},"community_data":{"facebook_likes":null,"twitter_followers":68552,"reddit_average_posts_48h":8.0,"reddit_average_comments_48h":363.727,"reddit_subscribers":1192543,"reddit_accounts_active_48h":"4102.33333333333"},"developer_data":{"forks":24627,"stars":41240,"subscribers":3497,"total_issues":0,"closed_issues":0,"pull_requests_merged":6982,"pull_request_contributors":623,"code_additions_deletions_4_weeks":{"additions":3706,"deletions":-1803},"commit_count_4_weeks":370},"public_interest_stats":{"alexa_rank":12740,"bing_matches":135000000}} \ No newline at end of file diff --git a/fiat/mock_data/23-09-2011.json b/fiat/mock_data/23-09-2011.json deleted file mode 100644 index 94ba1bd575..0000000000 --- a/fiat/mock_data/23-09-2011.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}} diff --git a/fiat/mock_data/27-04-2013.json b/fiat/mock_data/27-04-2013.json deleted file mode 100644 index 94ba1bd575..0000000000 --- a/fiat/mock_data/27-04-2013.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}} diff --git a/fiat/mock_data/28-04-2013.json b/fiat/mock_data/28-04-2013.json deleted file mode 100644 index b9f2a4ad92..0000000000 --- a/fiat/mock_data/28-04-2013.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":130.7952,"brl":268.8555,"btc":1.0,"cad":136.8008,"chf":126.7471,"cny":830.3415,"dkk":769.4261,"eur":103.1862,"gbp":86.889,"hkd":1043.747,"idr":1306348.9692,"inr":7304.2353,"jpy":13203.1967,"krw":149390.4586,"mxn":1633.6086,"myr":407.992,"nzd":158.5211,"php":5543.837,"pln":429.2283,"rub":4203.4233,"sek":884.1254,"sgd":166.2931,"usd":135.3,"xag":5.716,"xau":0.0938,"zar":1223.2239},"market_cap":{"aud":1450558006.5599997,"brl":2981688151.6499996,"btc":11090299.999999998,"cad":1517161912.2399998,"chf":1405663363.1299999,"cny":9208736337.449999,"dkk":8533166276.829999,"eur":1144365913.86,"gbp":963625076.6999998,"hkd":11575467354.099998,"idr":14487801973118.756,"inr":81006160747.59,"jpy":146427412362.00998,"krw":1656785003011.5798,"mxn":18117209456.579998,"myr":4524753677.599999,"nzd":1758046555.3299997,"php":61482815481.09999,"pln":4760270615.489999,"rub":46617225423.99,"sek":9805215923.619999,"sgd":1844240366.9299998,"usd":1500517590,"xag":63392154.79999999,"xau":1040270.1399999998,"zar":13565920018.169998},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"usd":0,"xag":0.0,"xau":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}} diff --git a/fiat/mock_data/29-04-2013.json b/fiat/mock_data/29-04-2013.json deleted file mode 100644 index d7f64132d7..0000000000 --- a/fiat/mock_data/29-04-2013.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":140.0534,"brl":287.7259,"btc":1.0,"cad":146.3907,"chf":135.5619,"cny":889.0842,"dkk":822.6608,"eur":110.3745,"gbp":93.0697,"hkd":1117.9148,"idr":1394387.3129,"inr":7822.3338,"jpy":14108.4087,"krw":159839.6429,"mxn":1748.706,"myr":436.5932,"nzd":169.7084,"php":5937.7695,"pln":459.2644,"rub":4501.5503,"sek":944.2334,"sgd":178.0707,"twd":4262.3287,"usd":141.96,"xag":6.1223,"xau":0.1005,"xdr":95.6015,"zar":1309.9167},"market_cap":{"aud":1553878467.66,"brl":3192290087.91,"btc":11094900.0,"cad":1624190177.43,"chf":1504045724.31,"cny":9864300290.58,"dkk":9127339309.92,"eur":1224594040.05,"gbp":1032599014.53,"hkd":12403152914.52,"idr":15470587797894.21,"inr":86788011277.62,"jpy":156531383685.63,"krw":1773404854011.21,"mxn":19401718199.4,"myr":4843957894.68,"nzd":1882897727.16,"php":65878958825.55,"pln":5095492591.56,"rub":49944250423.47,"sek":10476175149.66,"sgd":1975676609.43,"twd":47290110693.63,"usd":1575032004.0,"xag":67926306.27,"xau":1115037.45,"xdr":1060689082.35,"zar":14533394794.83},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"usd":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}} diff --git a/fiat/mock_data/coinlist.json b/fiat/mock_data/coinlist.json new file mode 100644 index 0000000000..ccf9278733 --- /dev/null +++ b/fiat/mock_data/coinlist.json @@ -0,0 +1,30 @@ +[ + { "id": "01coin", "symbol": "zoc", "name": "01coin", "platforms": {} }, + { + "id": "0-5x-long-algorand-token", + "symbol": "algohalf", + "name": "0.5X Long Algorand Token", + "platforms": { "ethereum": "" } + }, + { "id": "ethereum", "symbol": "eth", "name": "Ethereum", "platforms": {} }, + { + "id": "ethereum-cash-token", + "symbol": "ecash", + "name": "Ethereum Cash Token", + "platforms": { "ethereum": "0x906710835d1ae85275eb770f06873340ca54274b" } + }, + { + "id": "santa-shiba", + "symbol": "santashib", + "name": "Santa Shiba", + "platforms": { + "binance-smart-chain": "0x74c609b16512869b1873f5a9d7999deee386e740" + } + }, + { + "id": "vendit", + "symbol": "vndt", + "name": "Vendit", + "platforms": { "ethereum": "0x5e9997684d061269564f94e5d11ba6ce6fa9528c" } + } +] 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/current.json b/fiat/mock_data/current.json deleted file mode 100644 index 7d66dd3eb8..0000000000 --- a/fiat/mock_data/current.json +++ /dev/null @@ -1 +0,0 @@ -{"id":"bitcoin","symbol":"btc","name":"Bitcoin","asset_platform_id":null,"block_time_in_minutes":10,"categories":["Cryptocurrency"],"description":{"en":"Bitcoin is the first successful internet money based on peer-to-peer technology; whereby no central bank or authority is involved in the transaction and production of the Bitcoin currency. It was created by an anonymous individual/group under the name, Satoshi Nakamoto. The source code is available publicly as an open source project, anybody can look at it and be part of the developmental process.\r\n\r\nBitcoin is changing the way we see money as we speak. The idea was to produce a means of exchange, independent of any central authority, that could be transferred electronically in a secure, verifiable and immutable way. It is a decentralized peer-to-peer internet currency making mobile payment easy, very low transaction fees, protects your identity, and it works anywhere all the time with no central authority or banks.\r\n\r\nBitcoin is design to have only 21 million BTC ever created, thus making it a deflationary currency. Bitcoin uses the \u003ca href=\"https://www.coingecko.com/en?hashing_algorithm=SHA-256\"\u003eSHA-256\u003c/a\u003e hashing algorithm with an average transaction confirmation time of 10 minutes. Miners today are mining Bitcoin using ASIC chip dedicated to only mining Bitcoin, and the hash rate has shot up to peta hashes.\r\n\r\nBeing the first successful online cryptography currency, Bitcoin has inspired other alternative currencies such as \u003ca href=\"https://www.coingecko.com/en/coins/litecoin\"\u003eLitecoin\u003c/a\u003e, \u003ca href=\"https://www.coingecko.com/en/coins/peercoin\"\u003ePeercoin\u003c/a\u003e, \u003ca href=\"https://www.coingecko.com/en/coins/primecoin\"\u003ePrimecoin\u003c/a\u003e, and so on.\r\n\r\nThe cryptocurrency then took off with the innovation of the turing-complete smart contract by \u003ca href=\"https://www.coingecko.com/en/coins/ethereum\"\u003eEthereum\u003c/a\u003e which led to the development of other amazing projects such as \u003ca href=\"https://www.coingecko.com/en/coins/eos\"\u003eEOS\u003c/a\u003e, \u003ca href=\"https://www.coingecko.com/en/coins/tron\"\u003eTron\u003c/a\u003e, and even crypto-collectibles such as \u003ca href=\"https://www.coingecko.com/buzz/ethereum-still-king-dapps-cryptokitties-need-1-billion-on-eos\"\u003eCryptoKitties\u003c/a\u003e."},"links":{"homepage":["http://www.bitcoin.org","",""],"blockchain_site":["https://blockchair.com/bitcoin/","https://btc.com/","","",""],"official_forum_url":["https://bitcointalk.org/","",""],"chat_url":["","",""],"announcement_url":["",""],"twitter_screen_name":"btc","facebook_username":"bitcoins","bitcointalk_thread_identifier":null,"telegram_channel_identifier":"","subreddit_url":"https://www.reddit.com/r/Bitcoin/","repos_url":{"github":["https://github.com/bitcoin/bitcoin","https://github.com/bitcoin/bips"],"bitbucket":[]}},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579","large":"https://assets.coingecko.com/coins/images/1/large/bitcoin.png?1547033579"},"country_origin":"","genesis_date":"2009-01-03","sentiment_votes_up_percentage":38.69,"sentiment_votes_down_percentage":61.31,"market_cap_rank":1,"coingecko_rank":1,"coingecko_score":87.571,"developer_score":91.999,"community_score":80.5,"liquidity_score":100.084,"public_interest_score":44.29,"market_data":{"current_price":{"aed":26233,"ars":427146,"aud":10530.78,"bch":34.754373,"bdt":605978,"bhd":2693.12,"bmd":7142.59,"bnb":469.136,"brl":29879,"btc":1.0,"cad":9489.19,"chf":7113.95,"clp":5688362,"cny":50277,"czk":165074,"dkk":48362,"eos":2760,"eth":48.466911,"eur":6471.78,"gbp":5562.63,"hkd":55896,"huf":2162211,"idr":100796288,"ils":24797,"inr":512355,"jpy":776361,"krw":8423047,"kwd":2169.53,"lkr":1286409,"ltc":152.386,"mmk":10823325,"mxn":138495,"myr":29800,"nok":65379,"nzd":11149.85,"php":363629,"pkr":1110673,"pln":27816,"rub":455640,"sar":26785,"sek":68693,"sgd":9743.25,"thb":215688,"try":40799,"twd":218342,"uah":172189,"usd":7142.59,"vef":1774846373,"vnd":166109525,"xag":418.79,"xau":4.88,"xdr":5191.74,"xlm":125849,"xrp":30745,"zar":104995},"roi":null,"ath":{"aed":72229,"ars":654921,"aud":25717,"bch":42.242547,"bdt":1631248,"bhd":7416.37,"bmd":19665.39,"bnb":143062,"brl":64777,"btc":1.003301,"cad":25303,"chf":19484.57,"clp":12582805,"cny":130006,"czk":429834,"dkk":124584,"eos":3287,"eth":624.203,"eur":16727.68,"gbp":14759.86,"hkd":153608,"huf":5263109,"idr":266681922,"ils":69096,"inr":1259942,"jpy":2214028,"krw":21418073,"kwd":5939.64,"lkr":3024387,"ltc":318.98,"mmk":26871485,"mxn":376059,"myr":80224,"nok":164805,"nzd":28131,"php":991988,"pkr":2181203,"pln":70407,"rub":1157051,"sar":73750,"sek":167278,"sgd":26517,"thb":639578,"try":80284,"twd":589706,"uah":541437,"usd":19665.39,"vef":3454441855,"vnd":446720468,"xag":1225.45,"xau":15.67,"xdr":13907.05,"xlm":189028,"xrp":42151,"zar":257660},"ath_change_percentage":{"aed":-63.62124,"ars":-34.67198,"aud":-58.99662,"bch":-17.39149,"bdt":-62.79247,"bhd":-63.62871,"bmd":-63.62129,"bnb":-99.67103,"brl":-53.8445,"btc":-0.32896,"cad":-62.45264,"chf":-63.4543,"clp":-54.7089,"cny":-61.26497,"czk":-61.55431,"dkk":-61.13905,"eos":-16.10009,"eth":-92.21431,"eur":-61.26899,"gbp":-62.27369,"hkd":-63.55334,"huf":-58.87639,"idr":-62.15657,"ils":-64.07374,"inr":-59.26992,"jpy":-64.90022,"krw":-60.60644,"kwd":-63.4152,"lkr":-57.39745,"ltc":-52.11769,"mmk":-59.65748,"mxn":-63.10475,"myr":-62.79525,"nok":-60.29271,"nzd":-60.32548,"php":-63.31142,"pkr":-48.99833,"pln":-60.4529,"rub":-60.55354,"sar":-63.62382,"sek":-58.87165,"sgd":-63.20581,"thb":-66.23726,"try":-49.0973,"twd":-62.92736,"uah":-68.14693,"usd":-63.62129,"vef":-48.53915,"vnd":-62.77453,"xag":-65.86833,"xau":-68.86202,"xdr":-62.60861,"xlm":-33.15539,"xrp":-27.01399,"zar":-59.19278},"ath_date":{"aed":"2017-12-16T00:00:00.000Z","ars":"2019-08-13T13:41:31.186Z","aud":"2017-12-16T00:00:00.000Z","bch":"2018-12-15T16:19:57.060Z","bdt":"2017-12-16T00:00:00.000Z","bhd":"2017-12-16T00:00:00.000Z","bmd":"2017-12-16T00:00:00.000Z","bnb":"2017-10-19T00:00:00.000Z","brl":"2017-12-16T00:00:00.000Z","btc":"2019-10-15T16:00:56.136Z","cad":"2017-12-16T00:00:00.000Z","chf":"2017-12-16T00:00:00.000Z","clp":"2017-12-16T00:00:00.000Z","cny":"2017-12-16T00:00:00.000Z","czk":"2017-12-16T00:00:00.000Z","dkk":"2017-12-16T00:00:00.000Z","eos":"2019-09-05T20:22:13.572Z","eth":"2015-10-20T00:00:00.000Z","eur":"2017-12-16T00:00:00.000Z","gbp":"2017-12-16T00:00:00.000Z","hkd":"2017-12-16T00:00:00.000Z","huf":"2017-12-16T00:00:00.000Z","idr":"2017-12-16T00:00:00.000Z","ils":"2017-12-16T00:00:00.000Z","inr":"2017-12-16T00:00:00.000Z","jpy":"2017-12-16T00:00:00.000Z","krw":"2017-12-16T00:00:00.000Z","kwd":"2017-12-16T00:00:00.000Z","lkr":"2017-12-16T00:00:00.000Z","ltc":"2017-03-05T00:00:00.000Z","mmk":"2017-12-16T00:00:00.000Z","mxn":"2017-12-16T00:00:00.000Z","myr":"2017-12-16T00:00:00.000Z","nok":"2017-12-16T00:00:00.000Z","nzd":"2017-12-16T00:00:00.000Z","php":"2017-12-16T00:00:00.000Z","pkr":"2019-06-26T19:55:29.614Z","pln":"2017-12-16T00:00:00.000Z","rub":"2017-12-16T00:00:00.000Z","sar":"2017-12-16T00:00:00.000Z","sek":"2017-12-16T00:00:00.000Z","sgd":"2017-12-16T00:00:00.000Z","thb":"2017-12-16T00:00:00.000Z","try":"2019-06-26T19:55:29.614Z","twd":"2017-12-16T00:00:00.000Z","uah":"2017-12-16T00:00:00.000Z","usd":"2017-12-16T00:00:00.000Z","vef":"2019-06-26T19:55:29.614Z","vnd":"2017-12-16T00:00:00.000Z","xag":"2017-12-16T00:00:00.000Z","xau":"2017-12-16T00:00:00.000Z","xdr":"2017-12-16T00:00:00.000Z","xlm":"2019-09-12T22:33:57.455Z","xrp":"2019-09-06T12:53:39.935Z","zar":"2017-12-16T00:00:00.000Z"},"market_cap":{"aed":474793759318,"ars":7730972325788,"aud":190537867813,"bch":630551829,"bdt":10967221735647,"bhd":48741175024,"bmd":129269449023,"bnb":8504056047,"brl":540242881359,"btc":18061500,"cad":171672542961,"chf":128668733894,"clp":102976043092026,"cny":909940578620,"czk":2986033783826,"dkk":874823757177,"eos":49831754917,"eth":878149440,"eur":117068610616,"gbp":100617263456,"hkd":1011622634528,"huf":39109243612989,"idr":1823601645999521,"ils":448552061166,"inr":9272819588645,"jpy":14042144114110,"krw":152458449136441,"kwd":39265078063,"lkr":23281924163795,"ltc":2759842022,"mmk":195884749415125,"mxn":2507103402139,"myr":539325068270,"nok":1182462552968,"nzd":201673138152,"php":6576324680166,"pkr":20101399323136,"pln":503125744460,"rub":8247196943518,"sar":484759011874,"sek":1243159730063,"sgd":176297674578,"thb":3901906020384,"try":738440868912,"twd":3950345221975,"uah":3116344627531,"usd":129269449023,"vef":32121860601613224,"vnd":3004844249179323,"xag":7557859850,"xau":88142374,"xdr":93962084412,"xlm":2283168044885,"xrp":555894381400,"zar":1899894292486},"market_cap_rank":1,"total_volume":{"aed":138687050476,"ars":2258211477718,"aud":55673594578,"bch":183737598,"bdt":3203651532313,"bhd":14237857483,"bmd":37761091954,"bnb":2480204906,"brl":157962199864,"btc":5303296,"cad":50166932300,"chf":37609632215,"clp":30072933632557,"cny":265804102377,"czk":872704148379,"dkk":255678541091,"eos":14590103457,"eth":256232321,"eur":34214645720,"gbp":29408225131,"hkd":295508865363,"huf":11431065850716,"idr":532884529661782,"ils":131094616522,"inr":2708689636557,"jpy":4104423009447,"krw":44530522909173,"kwd":11469780637,"lkr":6800917663597,"ltc":805626595,"mmk":57220186912138,"mxn":732187572998,"myr":157543051743,"nok":345640000147,"nzd":58946461701,"php":1922417191403,"pkr":5871849798923,"pln":147054530842,"rub":2408855577961,"sar":141607455567,"sek":363160165026,"sgd":51510094341,"thb":1140290574296,"try":215692490077,"twd":1154318782196,"uah":910320086696,"usd":37761091954,"vef":9383164708217112,"vnd":878179122153079,"xag":2214048370,"xau":25773833,"xdr":27447404909,"xlm":665333514482,"xrp":162538792944,"zar":555079555485},"high_24h":{"aed":28288,"ars":460171,"aud":11341.92,"bch":35.462898,"bdt":653121,"bhd":2902.95,"bmd":7701.17,"bnb":479.999,"brl":32313,"btc":1.0,"cad":10224.26,"chf":7651.19,"clp":6150290,"cny":54135,"czk":177709,"dkk":52023,"eos":2804,"eth":49.32148,"eur":6961.83,"gbp":5962.03,"hkd":60252,"huf":2329092,"idr":108526800,"ils":26652,"inr":552563,"jpy":836775,"krw":9070591,"kwd":2338.72,"lkr":1386522,"ltc":155.932,"mmk":11702598,"mxn":149346,"myr":32102,"nok":70413,"nzd":12023.03,"php":391391,"pkr":1198463,"pln":29923,"rub":490607,"sar":28878,"sek":74080,"sgd":10492.07,"thb":232716,"try":43892,"twd":235078,"uah":186500,"usd":7701.17,"vef":1913645346,"vnd":178999196,"xag":450.44,"xau":5.26,"xdr":5594.65,"xlm":128230,"xrp":31649,"zar":113045},"low_24h":{"aed":25389,"ars":413252,"aud":10177.28,"bch":33.498779,"bdt":586479,"bhd":2606.33,"bmd":6912.76,"bnb":441.91,"brl":28872,"btc":1.0,"cad":9172.15,"chf":6875.83,"clp":5495871,"cny":48647,"czk":159479,"dkk":46725,"eos":2688,"eth":47.221694,"eur":6252.27,"gbp":5375.94,"hkd":54096,"huf":2089289,"idr":97454723,"ils":23978,"inr":495700,"jpy":750674,"krw":8145203,"kwd":2099.06,"lkr":1245015,"ltc":149.451,"mmk":10475054,"mxn":133891,"myr":28843,"nok":63132,"nzd":10775.68,"php":351479,"pkr":1073206,"pln":26857,"rub":440210,"sar":25927,"sek":66395,"sgd":9421.43,"thb":208711,"try":39550,"twd":211136,"uah":166648,"usd":6912.76,"vef":1717735697,"vnd":160482399,"xag":404.21,"xau":4.71,"xdr":5021.89,"xlm":124993,"xrp":30492,"zar":101450},"price_change_24h":-480.5673621733,"price_change_percentage_24h":-6.30404,"price_change_percentage_7d":-17.44599,"price_change_percentage_14d":-22.54054,"price_change_percentage_30d":-11.22023,"price_change_percentage_60d":-28.86786,"price_change_percentage_200d":24.23364,"price_change_percentage_1y":54.82116,"market_cap_change_24h":-9006537915.032,"market_cap_change_percentage_24h":-6.51345,"price_change_24h_in_currency":{"aed":-1765.3849375,"ars":-28250.7846463,"aud":-687.42268421,"bch":1.209693,"bdt":-40527.63640467,"bhd":-180.62666736,"bmd":-480.56736217,"bnb":13.500179,"brl":-2234.43033708,"btc":0.0,"cad":-631.36102032,"chf":-447.60591608,"clp":-374343.553352,"cny":-3307.29242038,"czk":-10750.48677935,"dkk":-3087.31721729,"eos":64.38,"eth":1.189393,"eur":-412.89174814,"gbp":-335.73696182,"hkd":-3726.49580785,"huf":-136103.17232295,"idr":-6721091.02167486,"ils":-1585.24792676,"inr":-34656.74698489,"jpy":-51434.05221209,"krw":-545602.5158788,"kwd":-145.59687908,"lkr":-83492.89071827,"ltc":0.41120093,"mmk":-738928.15288522,"mxn":-9373.69014498,"myr":-1954.66222593,"nok":-4225.97032316,"nzd":-741.18047963,"php":-24202.68533704,"pkr":-74347.06674375,"pln":-1774.3736239,"rub":-30122.73186387,"sar":-1801.65200366,"sek":-4761.52882112,"sgd":-641.55584025,"thb":-14530.99082294,"try":-2654.49777029,"twd":-14416.02940028,"uah":-12075.69031495,"usd":-480.56736217,"vef":-119415050.76443768,"vnd":-10911575.70808423,"xag":-25.13762803,"xau":-0.3155855,"xdr":-347.48043979,"xlm":-1338.794227113,"xrp":-784.6183422333,"zar":-7137.11714324},"price_change_percentage_1h_in_currency":{"aed":0.84905,"ars":0.86161,"aud":0.8867,"bch":-1.38159,"bdt":0.85317,"bhd":0.85317,"bmd":0.85317,"bnb":-1.51553,"brl":0.91348,"btc":0.0,"cad":0.915,"chf":0.88072,"clp":0.78989,"cny":0.87467,"czk":0.97735,"dkk":0.94907,"eos":-0.63738,"eth":-1.42231,"eur":0.94977,"gbp":0.91433,"hkd":0.86091,"huf":0.9903,"idr":0.88666,"ils":0.8754,"inr":0.90239,"jpy":0.87893,"krw":0.96319,"kwd":0.86978,"lkr":0.85317,"ltc":-1.45742,"mmk":0.85317,"mxn":0.85889,"myr":0.84592,"nok":1.07686,"nzd":0.85963,"php":1.01808,"pkr":0.85317,"pln":0.98571,"rub":0.95446,"sar":0.85328,"sek":0.92645,"sgd":0.9031,"thb":0.87489,"try":0.72911,"twd":0.89938,"uah":0.85317,"usd":0.85317,"vef":0.85317,"vnd":0.95458,"xag":1.08837,"xau":1.10648,"xdr":0.85317,"xlm":-1.37016,"xrp":0.15375,"zar":1.04254},"price_change_percentage_24h_in_currency":{"aed":-6.30532,"ars":-6.20356,"aud":-6.12774,"bch":3.60621,"bdt":-6.26872,"bhd":-6.2854,"bmd":-6.30404,"bnb":2.96293,"brl":-6.95795,"btc":0.0,"cad":-6.23841,"chf":-5.9195,"clp":-6.17453,"cny":-6.17208,"czk":-6.11433,"dkk":-6.00068,"eos":2.38856,"eth":2.51577,"eur":-5.99726,"gbp":-5.69203,"hkd":-6.25013,"huf":-5.92187,"idr":-6.25117,"ils":-6.0088,"inr":-6.33565,"jpy":-6.21338,"krw":-6.08344,"kwd":-6.28893,"lkr":-6.09481,"ltc":0.27057,"mmk":-6.39087,"mxn":-6.3392,"myr":-6.15559,"nok":-6.0714,"nzd":-6.2331,"php":-6.24051,"pkr":-6.27391,"pln":-5.99652,"rub":-6.20112,"sar":-6.30234,"sek":-6.48232,"sgd":-6.17783,"thb":-6.3118,"try":-6.10886,"twd":-6.19357,"uah":-6.55345,"usd":-6.30404,"vef":-6.30404,"vnd":-6.164,"xag":-5.66252,"xau":-6.07975,"xdr":-6.2731,"xlm":-1.05261,"xrp":-2.48855,"zar":-6.36494},"price_change_percentage_7d_in_currency":{"aed":-17.4561,"ars":-17.17189,"aud":-17.39343,"bch":11.54617,"bdt":-17.34556,"bhd":-17.42365,"bmd":-17.44599,"bnb":15.37505,"brl":-17.6939,"btc":0.0,"cad":-17.22355,"chf":-16.83939,"clp":-18.16528,"cny":-17.23081,"czk":-17.84696,"dkk":-17.5639,"eos":8.28045,"eth":3.50175,"eur":-17.56998,"gbp":-17.18365,"hkd":-17.46919,"huf":-17.62502,"idr":-17.26247,"ils":-17.71536,"inr":-17.71385,"jpy":-17.2919,"krw":-16.68652,"kwd":-17.43375,"lkr":-17.57364,"ltc":3.79137,"mmk":-17.47497,"mxn":-17.18117,"myr":-17.25559,"nok":-17.57382,"nzd":-17.76103,"php":-17.19551,"pkr":-17.66361,"pln":-17.28903,"rub":-17.622,"sar":-17.43643,"sek":-17.99201,"sgd":-17.30006,"thb":-17.44962,"try":-17.99541,"twd":-17.11248,"uah":-17.8811,"usd":-17.44599,"vef":-17.44599,"vnd":-17.01601,"xag":-17.68437,"xau":-17.19364,"xdr":-17.64438,"xlm":7.48208,"xrp":-4.57226,"zar":-18.1527},"price_change_percentage_14d_in_currency":{"aed":-22.55003,"ars":-22.26953,"aud":-21.20934,"bch":10.18823,"bdt":-22.4719,"bhd":-22.52698,"bmd":-22.54054,"bnb":3.78256,"brl":-20.98795,"btc":0.0,"cad":-21.89472,"chf":-22.44172,"clp":-16.95103,"cny":-21.8595,"czk":-22.52847,"dkk":-22.42537,"eos":3.89877,"eth":-1.80404,"eur":-22.42496,"gbp":-22.65386,"hkd":-22.54945,"huf":-22.08321,"idr":-21.79502,"ils":-23.09687,"inr":-21.7934,"jpy":-22.93533,"krw":-20.89782,"kwd":-22.52728,"lkr":-22.87826,"ltc":1.53118,"mmk":-22.72098,"mxn":-21.51393,"myr":-21.70354,"nok":-22.15384,"nzd":-22.92904,"php":-21.95551,"pkr":-22.80961,"pln":-21.72901,"rub":-22.2441,"sar":-22.54442,"sek":-22.62979,"sgd":-22.15219,"thb":-23.10963,"try":-23.08949,"twd":-21.78058,"uah":-23.91091,"usd":-22.54054,"vef":-22.54054,"vnd":-22.53941,"xag":-22.26463,"xau":-22.33687,"xdr":-22.55567,"xlm":1.69618,"xrp":-2.72537,"zar":-22.70147},"price_change_percentage_30d_in_currency":{"aed":-11.22443,"ars":-9.51444,"aud":-10.193,"bch":-1.85067,"bdt":-11.07576,"bhd":-11.21198,"bmd":-11.22023,"bnb":6.12033,"brl":-8.97239,"btc":0.0,"cad":-9.91713,"chf":-10.62881,"clp":-2.46349,"cny":-11.69817,"czk":-10.69528,"dkk":-10.45514,"eos":-0.60245,"eth":3.31477,"eur":-10.48102,"gbp":-10.92388,"hkd":-11.40589,"huf":-9.11838,"idr":-11.12997,"ils":-12.76593,"inr":-10.10704,"jpy":-11.04788,"krw":-10.68933,"kwd":-11.09524,"lkr":-11.91065,"ltc":1.16327,"mmk":-12.33699,"mxn":-10.07551,"myr":-11.44098,"nok":-11.18008,"nzd":-11.13426,"php":-11.70222,"pkr":-11.68762,"pln":-9.93606,"rub":-11.10484,"sar":-11.23433,"sek":-11.4895,"sgd":-11.12778,"thb":-11.50173,"try":-12.60444,"twd":-10.99896,"uah":-14.02182,"usd":-11.22023,"vef":-11.22023,"vnd":-11.12455,"xag":-8.57976,"xau":-9.77676,"xdr":-11.09094,"xlm":-2.0601,"xrp":11.18539,"zar":-10.5719},"price_change_percentage_60d_in_currency":{"aed":-28.87271,"ars":-24.94049,"aud":-28.98967,"bch":6.58558,"bdt":-28.55284,"bhd":-28.85919,"bmd":-28.86786,"bnb":-4.73332,"brl":-28.29542,"btc":0.0,"cad":-28.7742,"chf":-28.5492,"clp":-21.05412,"cny":-29.39446,"czk":-29.96813,"dkk":-28.93541,"eos":4.89171,"eth":2.15282,"eur":-28.98347,"gbp":-30.91919,"hkd":-29.00032,"huf":-28.93845,"idr":-28.57896,"ils":-29.88395,"inr":-28.33674,"jpy":-28.19392,"krw":-29.75392,"kwd":-28.86693,"lkr":-29.26237,"ltc":10.0907,"mmk":-29.68127,"mxn":-29.02416,"myr":-28.91758,"nok":-28.16281,"nzd":-30.41575,"php":-30.43231,"pkr":-29.27026,"pln":-30.19557,"rub":-29.14377,"sar":-28.88798,"sek":-29.4752,"sgd":-29.51848,"thb":-29.55026,"try":-29.23667,"twd":-29.86135,"uah":-29.77018,"usd":-28.86786,"vef":-28.86786,"vnd":-28.86503,"xag":-24.99432,"xau":-26.49988,"xdr":-29.12285,"xlm":-13.83671,"xrp":-14.54016,"zar":-29.92383},"price_change_percentage_200d_in_currency":{"aed":24.22488,"ars":66.85932,"aud":27.95174,"bch":76.94309,"bdt":24.91123,"bhd":24.2366,"bmd":24.23364,"bnb":86.35415,"brl":31.96581,"btc":0.0,"cad":22.59678,"chf":21.70062,"clp":46.20803,"cny":29.85077,"czk":25.05176,"dkk":26.08954,"eos":133.30477,"eth":36.87714,"eur":25.97742,"gbp":27.31032,"hkd":23.93812,"huf":30.22984,"idr":23.22097,"ils":20.37488,"inr":28.82094,"jpy":21.90092,"krw":25.82773,"kwd":24.06617,"lkr":26.37467,"ltc":100.2474,"mmk":23.76562,"mxn":26.42772,"myr":25.30265,"nok":30.11168,"nzd":28.35377,"php":22.01045,"pkr":36.51723,"pln":26.39489,"rub":21.26104,"sar":24.22499,"sek":24.9225,"sgd":24.29049,"thb":17.38252,"try":18.62205,"twd":22.88347,"uah":13.11625,"usd":24.23364,"vef":24.23364,"vnd":24.69572,"xag":8.68024,"xau":8.77097,"xdr":25.56291,"xlm":115.80188,"xrp":60.72697,"zar":26.48666},"price_change_percentage_1y_in_currency":{"aed":54.80299,"ars":155.34218,"aud":65.7028,"bch":78.89456,"bdt":57.19653,"bhd":54.81048,"bmd":54.82116,"bnb":-36.65258,"brl":70.52838,"btc":0.0,"cad":55.45654,"chf":55.10131,"clp":84.99866,"cny":57.31982,"czk":56.67972,"dkk":59.98455,"eos":129.05448,"eth":44.12795,"eur":59.73458,"gbp":54.03378,"hkd":54.68674,"huf":65.94232,"idr":49.69388,"ils":44.12632,"inr":56.02681,"jpy":48.86278,"krw":60.38647,"kwd":54.63991,"lkr":56.01994,"ltc":15.13364,"mmk":46.86442,"mxn":48.17348,"myr":54.61929,"nok":66.2395,"nzd":64.92835,"php":51.02989,"pkr":79.1998,"pln":59.54312,"rub":50.32841,"sar":54.6784,"sek":64.78944,"sgd":53.74122,"thb":42.06052,"try":66.34431,"twd":53.34085,"uah":34.53434,"usd":54.82116,"vef":54.80868,"vnd":54.8204,"xag":31.52974,"xau":29.51256,"xdr":56.55346,"xlm":458.8577,"xrp":200.09987,"zar":63.33739},"market_cap_change_24h_in_currency":{"aed":-33066285509.427795,"ars":-530278117251.8633,"aud":-12960274920.457092,"bch":24024713,"bdt":-759694248769.8789,"bhd":-3385553428.380455,"bmd":-9006537915.03212,"bnb":255092140,"brl":-42175575626.08362,"btc":1663,"cad":-12057111575.367401,"chf":-8491780658.011612,"clp":-7119297708339.391,"cny":-62028988767.01172,"czk":-203550613409.21533,"dkk":-58915127066.18848,"eos":1113960094,"eth":23139205,"eur":-7879783597.133743,"gbp":-6416093889.904633,"hkd":-69937566307.74951,"huf":-2567138850248.992,"idr":-127617002526785.75,"ils":-30288002282.410645,"inr":-646961300054.1992,"jpy":-965736921441.2344,"krw":-10313128196732.844,"kwd":-2728924342.179413,"lkr":-1566622601424.3633,"ltc":12247122,"mmk":-13842142728744.844,"mxn":-177409423821.13818,"myr":-36663555321.51227,"nok":-81603974747.83325,"nzd":-14103220841.779144,"php":-455362689074.46094,"pkr":-1393602846440.6172,"pln":-33879559789.90509,"rub":-570801019531.376,"sar":-33765015342.341797,"sek":-90223858525.68628,"sgd":-12077223463.969208,"thb":-272646025286.54932,"try":-49865416412.02966,"twd":-271842901196.03174,"uah":-226020631120.77588,"usd":-9006537915.03212,"vef":-2.238013371260464e+15,"vnd":-207810852801656.0,"xag":-501296089.4158993,"xau":-5993152.57134043,"xdr":-6570642236.026748,"xlm":-26680177257.057617,"xrp":-14067201416.741577,"zar":-135321097818.74121},"market_cap_change_percentage_24h_in_currency":{"aed":-6.51091,"ars":-6.41886,"aud":-6.36874,"bch":3.96103,"bdt":-6.47821,"bhd":-6.49485,"bmd":-6.51345,"bnb":3.09241,"brl":-7.24146,"btc":0.00921,"cad":-6.56242,"chf":-6.19113,"clp":-6.46648,"cny":-6.38178,"czk":-6.38173,"dkk":-6.30959,"eos":2.28656,"eth":2.70631,"eur":-6.30643,"gbp":-5.99448,"hkd":-6.46636,"huf":-6.1597,"idr":-6.54037,"ils":-6.32529,"inr":-6.52193,"jpy":-6.43487,"krw":-6.33595,"kwd":-6.49837,"lkr":-6.30469,"ltc":0.44574,"mmk":-6.60008,"mxn":-6.60863,"myr":-6.36533,"nok":-6.45567,"nzd":-6.53604,"php":-6.47587,"pkr":-6.48338,"pln":-6.30898,"rub":-6.47314,"sar":-6.51176,"sek":-6.76653,"sgd":-6.41127,"thb":-6.53114,"try":-6.32564,"twd":-6.43844,"uah":-6.7623,"usd":-6.51345,"vef":-6.51345,"vnd":-6.46851,"xag":-6.22021,"xau":-6.36652,"xdr":-6.53582,"xlm":-1.15506,"xrp":-2.4681,"zar":-6.64898},"total_supply":21000000.0,"circulating_supply":18061500.0,"last_updated":"2019-11-22T15:50:18.919Z"},"public_interest_stats":{"alexa_rank":12740,"bing_matches":135000000},"status_updates":[],"last_updated":"2019-11-22T15:50:18.919Z"} diff --git a/fiat/mock_data/market_chart_eth_other.json b/fiat/mock_data/market_chart_eth_other.json new file mode 100644 index 0000000000..aa416fdb18 --- /dev/null +++ b/fiat/mock_data/market_chart_eth_other.json @@ -0,0 +1,23 @@ +{ + "prices": [ + [1654560000000, 245991.30610738738], + [1654646400000, 241439.61063702923], + [1654732800000, 241272.47868584536], + [1654819200000, 240402.9616407818], + [1654874261000, 232687.7973743471] + ], + "market_caps": [ + [1654560000000, 29783749062026.934], + [1654646400000, 29309140822797.383], + [1654732800000, 29218371977967.83], + [1654819200000, 29135342816603.11], + [1654874261000, 28322159926577.836] + ], + "total_volumes": [ + [1654560000000, 2198234703995.5186], + [1654646400000, 3139844528072.9595], + [1654732800000, 2381462737920.105], + [1654819200000, 1407275835572.8992], + [1654874261000, 1875231811513.972] + ] +} diff --git a/fiat/mock_data/market_chart_eth_usd_1.json b/fiat/mock_data/market_chart_eth_usd_1.json new file mode 100644 index 0000000000..d70ef2e368 --- /dev/null +++ b/fiat/mock_data/market_chart_eth_usd_1.json @@ -0,0 +1,14 @@ +{ + "prices": [ + [1654819200000, 1788.4182866616045], + [1654871975000, 1741.4106052586249] + ], + "market_caps": [ + [1654819200000, 216720355679.05618], + [1654871975000, 210920939953.81134] + ], + "total_volumes": [ + [1654819200000, 10469080004.414614], + [1654871975000, 13875498345.972267] + ] +} diff --git a/fiat/mock_data/market_chart_eth_usd_max.json b/fiat/mock_data/market_chart_eth_usd_max.json new file mode 100644 index 0000000000..9d1fb3bd02 --- /dev/null +++ b/fiat/mock_data/market_chart_eth_usd_max.json @@ -0,0 +1,17 @@ +{ + "prices": [ + [1654560000000, 1860.1813068416047], + [1654646400000, 1818.3877119829308], + [1654732800000, 1794.539625671828] + ], + "market_caps": [ + [1654560000000, 225224111085.68793], + [1654646400000, 220727955347.00992], + [1654732800000, 217320792647.69748] + ], + "total_volumes": [ + [1654560000000, 16623006597.793545], + [1654646400000, 23647547692.445885], + [1654732800000, 17712874976.607395] + ] +} diff --git a/fiat/mock_data/market_chart_token_other.json b/fiat/mock_data/market_chart_token_other.json new file mode 100644 index 0000000000..2a439f387c --- /dev/null +++ b/fiat/mock_data/market_chart_token_other.json @@ -0,0 +1,23 @@ +{ + "prices": [ + [1654560000000, 43129640.779293984], + [1654646400000, 42170403.75197084], + [1654732800000, 41617340.4960857], + [1654819200000, 41464477.97624925], + [1654893557000, 39012012.89610346] + ], + "market_caps": [ + [1654560000000, 5221982916522588], + [1654646400000, 5118923172979404], + [1654732800000, 5039907336185186], + [1654819200000, 5024661446418917], + [1654893557000, 4722632860950729] + ], + "total_volumes": [ + [1654560000000, 385416357318398.5], + [1654646400000, 548412545554966], + [1654732800000, 410780981662688], + [1654819200000, 242725619902352.5], + [1654893557000, 395315245827820.75] + ] +} diff --git a/fiat/mock_data/simpleprice_base.json b/fiat/mock_data/simpleprice_base.json new file mode 100644 index 0000000000..0f098214e1 --- /dev/null +++ b/fiat/mock_data/simpleprice_base.json @@ -0,0 +1,12 @@ +{ + "ethereum": { + "btc": 0.07531005, + "eth": 1.0, + "ltc": 29.097696, + "usd": 2299.72, + "eur": 2182.99, + "aed": 8447.1, + "ars": 268901, + "aud": 3314.36 + } +} 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.json b/fiat/mock_data/simpleprice_tokens.json new file mode 100644 index 0000000000..3d8e71dab7 --- /dev/null +++ b/fiat/mock_data/simpleprice_tokens.json @@ -0,0 +1,8 @@ +{ + "ethereum-cash-token": { + "eth": 1.39852e-10 + }, + "vendit": { + "eth": 5.58195e-07 + } +} \ No newline at end of file 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.json b/fiat/mock_data/vs_currencies.json new file mode 100644 index 0000000000..76cd47dbe0 --- /dev/null +++ b/fiat/mock_data/vs_currencies.json @@ -0,0 +1,10 @@ +[ + "btc", + "eth", + "ltc", + "usd", + "eur", + "aed", + "ars", + "aud" +] 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/fourbyte/fourbyte.go b/fourbyte/fourbyte.go new file mode 100644 index 0000000000..2be1a158e1 --- /dev/null +++ b/fourbyte/fourbyte.go @@ -0,0 +1,200 @@ +package fourbyte + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "strconv" + "strings" + "time" + + "github.com/golang/glog" + "github.com/linxGnu/grocksdb" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/db" +) + +// Coingecko is a structure that implements RatesDownloaderInterface +type FourByteSignaturesDownloader struct { + url string + httpTimeoutSeconds time.Duration + db *db.RocksDB +} + +// NewFourByteSignaturesDownloader initializes the downloader for FourByteSignatures API. +func NewFourByteSignaturesDownloader(db *db.RocksDB, url string) (*FourByteSignaturesDownloader, error) { + return &FourByteSignaturesDownloader{ + url: url, + httpTimeoutSeconds: 15 * time.Second, + db: db, + }, nil +} + +// Run starts the FourByteSignatures downloader +func (fd *FourByteSignaturesDownloader) Run() { + period := time.Hour * 24 + timer := time.NewTimer(period) + for { + fd.downloadSignatures() + <-timer.C + timer.Reset(period) + } +} + +type signatureData struct { + Id int `json:"id"` + TextSignature string `json:"text_signature"` + HexSignature string `json:"hex_signature"` +} + +type signaturesPage struct { + Count int `json:"count"` + Next string `json:"next"` + Results []signatureData `json:"results"` +} + +func (fd *FourByteSignaturesDownloader) getPage(url string) (*signaturesPage, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + glog.Errorf("Error creating a new request for %v: %v", url, err) + return nil, err + } + req.Close = true + req.Header.Set("Content-Type", "application/json") + client := &http.Client{ + Timeout: fd.httpTimeoutSeconds, + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, errors.New("Invalid response status: " + string(resp.Status)) + } + bodyBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var data signaturesPage + err = json.Unmarshal(bodyBytes, &data) + if err != nil { + glog.Errorf("Error parsing 4byte signatures response from %s: %v", url, err) + return nil, err + } + return &data, nil +} + +func (fd *FourByteSignaturesDownloader) getPageWithRetry(url string) (*signaturesPage, error) { + for retry := 1; retry <= 16; retry++ { + page, err := fd.getPage(url) + if err == nil && page != nil { + return page, err + } + glog.Errorf("Error getting 4byte signatures from %s: %v, retry count %d", url, err, retry) + timer := time.NewTimer(time.Second * time.Duration(retry)) + <-timer.C + } + return nil, errors.New("Too many retries to 4byte signatures") +} + +func parseSignatureFromText(t string) *bchain.FourByteSignature { + s := strings.Index(t, "(") + e := strings.LastIndex(t, ")") + if s < 0 || e < 0 { + return nil + } + var signature bchain.FourByteSignature + signature.Name = t[:s] + params := t[s+1 : e] + if len(params) > 0 { + s = 0 + tupleDepth := 0 + // parse params as comma separated list + // tuple is regarded as one parameter and not parsed further + for i, c := range params { + if c == ',' && tupleDepth == 0 { + signature.Parameters = append(signature.Parameters, params[s:i]) + s = i + 1 + } else if c == '(' { + tupleDepth++ + } else if c == ')' { + tupleDepth-- + } + } + signature.Parameters = append(signature.Parameters, params[s:]) + } + return &signature +} + +func (fd *FourByteSignaturesDownloader) downloadSignatures() { + period := time.Millisecond * 100 + timer := time.NewTimer(period) + url := fd.url + results := make([]signatureData, 0) + glog.Info("FourByteSignaturesDownloader starting download") + for { + page, err := fd.getPageWithRetry(url) + if err != nil { + glog.Errorf("Error getting 4byte signatures from %s: %v", url, err) + return + } + if page == nil { + glog.Errorf("Empty page from 4byte signatures from %s: %v", url, err) + return + } + glog.Infof("FourByteSignaturesDownloader downloaded %s with %d results", url, len(page.Results)) + if len(page.Results) > 0 { + fourBytes, err := strconv.ParseUint(page.Results[0].HexSignature, 0, 0) + if err != nil { + glog.Errorf("Invalid 4byte signature %+v on page %s: %v", page.Results[0], url, err) + return + } + sig, err := fd.db.GetFourByteSignature(uint32(fourBytes), uint32(page.Results[0].Id)) + if err != nil { + glog.Errorf("db.GetFourByteSignature error %+v on page %s: %v", page.Results[0], url, err) + return + } + // signature is already stored in db, break + if sig != nil { + break + } + results = append(results, page.Results...) + } + if page.Next == "" { + // at the end + break + } + url = page.Next + // wait a bit to not to flood the server + <-timer.C + timer.Reset(period) + } + if len(results) > 0 { + glog.Infof("FourByteSignaturesDownloader storing %d new signatures", len(results)) + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + + for i := range results { + r := &results[i] + fourBytes, err := strconv.ParseUint(r.HexSignature, 0, 0) + if err != nil { + glog.Errorf("Invalid 4byte signature %+v: %v", r, err) + return + } + fbs := parseSignatureFromText(r.TextSignature) + if fbs != nil { + fd.db.StoreFourByteSignature(wb, uint32(fourBytes), uint32(r.Id), fbs) + } else { + glog.Errorf("FourByteSignaturesDownloader invalid signature %s", r.TextSignature) + } + } + + if err := fd.db.WriteBatch(wb); err != nil { + glog.Errorf("FourByteSignaturesDownloader failed to store signatures, %v", err) + } + + } + glog.Infof("FourByteSignaturesDownloader finished") +} diff --git a/fourbyte/fourbyte_test.go b/fourbyte/fourbyte_test.go new file mode 100644 index 0000000000..c64ddad5ce --- /dev/null +++ b/fourbyte/fourbyte_test.go @@ -0,0 +1,55 @@ +package fourbyte + +import ( + "reflect" + "testing" + + "github.com/trezor/blockbook/bchain" +) + +func Test_parseSignatureFromText(t *testing.T) { + tests := []struct { + name string + signature string + want bchain.FourByteSignature + }{ + { + name: "_gonsPerFragment", + signature: "_gonsPerFragment()", + want: bchain.FourByteSignature{ + Name: "_gonsPerFragment", + }, + }, + { + name: "vestingDeposits", + signature: "vestingDeposits(address)", + want: bchain.FourByteSignature{ + Name: "vestingDeposits", + Parameters: []string{"address"}, + }, + }, + { + name: "batchTransferTokenB", + signature: "batchTransferTokenB(address[],uint256)", + want: bchain.FourByteSignature{ + Name: "batchTransferTokenB", + Parameters: []string{"address[]", "uint256"}, + }, + }, + { + name: "transmitAndSellTokenForEth", + signature: "transmitAndSellTokenForEth(address,uint256,uint256,uint256,address,(uint8,bytes32,bytes32),bytes)", + want: bchain.FourByteSignature{ + Name: "transmitAndSellTokenForEth", + Parameters: []string{"address", "uint256", "uint256", "uint256", "address", "(uint8,bytes32,bytes32)", "bytes"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseSignatureFromText(tt.signature); !reflect.DeepEqual(*got, tt.want) { + t.Errorf("parseSignatureFromText() = %v, want %v", *got, tt.want) + } + }) + } +} diff --git a/go.mod b/go.mod index 8a503f51e7..ee50fbaf7c 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,12 @@ -module github.com/syscoin/blockbook +module github.com/trezor/blockbook -go 1.22 - -toolchain go1.22.1 +go 1.25.0 require ( - github.com/Groestlcoin/go-groestl-hash v0.0.0-20181012171753-790653ac190c // indirect + 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,62 +14,78 @@ 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.17 - github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect - github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect - github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect - github.com/flier/gorocksdb v0.0.0-20210322035443-567cc51a1652 - github.com/gogo/protobuf v1.3.2 - github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b - github.com/golang/protobuf v1.4.3 + 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.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/prometheus/client_golang v1.8.0 + 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.23.2 github.com/schancel/cashaddr-converter v0.0.0-20181111022653-4769e7add95a - golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 - 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 + gopkg.in/yaml.v3 v3.0.1 ) -require github.com/syscoin/syscoinwire v1.0.4 - require ( - github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6 // 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/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/bits-and-blooms/bitset v1.20.0 // indirect github.com/btcsuite/btcd v0.24.2 // indirect - github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect - github.com/cespare/xxhash/v2 v2.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-ole/go-ole v1.2.1 // indirect - github.com/go-stack/stack v1.8.0 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect - github.com/prometheus/client_model v0.2.0 // indirect - github.com/prometheus/common v0.14.0 // indirect - github.com/prometheus/procfs v0.2.0 // indirect - github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect - github.com/tklauser/go-sysconf v0.3.5 // indirect - github.com/tklauser/numcpus v0.2.2 // indirect - golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912 // indirect - google.golang.org/protobuf v1.23.0 // indirect - gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // 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/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/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect + github.com/syscoin/syscoinwire v1.0.5 // 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 ) // replace github.com/martinboehm/btcutil => ../btcutil diff --git a/go.sum b/go.sum index cf6b60a248..573a0db29d 100644 --- a/go.sum +++ b/go.sum @@ -1,86 +1,29 @@ -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.43.0/go.mod h1:BOSR3VbTLkk6FDC/TcffxP4NF/FFBGA5ku+jvKOP7pg= -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.51.0/go.mod h1:hWtGJ6gnXH+KgDv+V0zFGDvpi07n3z8ZNj3T1RW0Gcw= -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/bigtable v1.2.0/go.mod h1:JcVAOl45lrTmQfLj7T6TxyMzIN/3FGGcFm+2xVAli2o= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -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/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -collectd.org v0.3.0/go.mod h1:A/8DzQBkF6abtvrT2j/AU/4tiBgJWYyh0y/oB/4MlWE= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/Azure/azure-sdk-for-go/sdk/azcore v0.21.1/go.mod h1:fBF9PQNqB8scdgpZ3ufzaLntG0AG7C1WjPMsiFOmfHM= -github.com/Azure/azure-sdk-for-go/sdk/internal v0.8.3/go.mod h1:KLF4gFr6DcKFZwSuH8w8yEK6DpFl3LP5rhdvAb7Yz5I= -github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.3.0/go.mod h1:tPaiy8S5bQ+S5sOiDlINkp7+Ef339+Nz5L5XO+cnOHo= -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/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +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/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= -github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= -github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6 h1:fLjPD/aNc3UIOA6tDi6QXUemppXK3P9BI7mr2hd6gx8= -github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= -github.com/VictoriaMetrics/fastcache v1.6.0 h1:C/3Oi3EiBCqufydp1neRZkqcwmEiuRT9c3fqvvgKm5o= -github.com/VictoriaMetrics/fastcache v1.6.0/go.mod h1:0qHz5QP0GMX4pfmMA/zt5RgfNuXJrTP0zS7DqpHGGTw= -github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= +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/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/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= 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/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= -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/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= -github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= -github.com/apache/arrow/go/arrow v0.0.0-20191024131854-af6fa24be0db/go.mod h1:VTxUBvSJ3s3eHAg65PNgrsn5BtqCRPdmyXh6rAfdxN0= -github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= -github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= -github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= -github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= -github.com/aws/aws-sdk-go-v2 v1.2.0/go.mod h1:zEQs02YRBw1DjK0PoJv3ygDYOFTre1ejlJWl8FwAuQo= -github.com/aws/aws-sdk-go-v2/config v1.1.1/go.mod h1:0XsVy9lBI/BCXm+2Tuvt39YmdHwS5unDQmxZOYe8F5Y= -github.com/aws/aws-sdk-go-v2/credentials v1.1.1/go.mod h1:mM2iIjwl7LULWtS6JCACyInboHirisUUdkBPoTHMOUo= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.0.2/go.mod h1:3hGg3PpiEjHnrkrlasTfxFqUsZ2GCk/fMUn4CbKgSkM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.0.2/go.mod h1:45MfaXZ0cNbeuT0KQ1XJylq8A6+OpVV2E5kvY/Kq+u8= -github.com/aws/aws-sdk-go-v2/service/route53 v1.1.1/go.mod h1:rLiOUrPLW/Er5kRcQ7NkwbjlijluLsrIbu/iyl35RO4= -github.com/aws/aws-sdk-go-v2/service/sso v1.1.1/go.mod h1:SuZJxklHxLAXgLTc1iFXbEWkXs7QRTQpCLGaKIprQW0= -github.com/aws/aws-sdk-go-v2/service/sts v1.1.1/go.mod h1:Wi0EBZwiz/K44YliU0EKxqTCJGUfYTWXrrBwkq736bM= -github.com/aws/smithy-go v1.1.0/go.mod h1:EzMw8dbp/YJL4A5/sbhGddag+NPT7q084agLbB9LgIw= -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/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= -github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= +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 v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg= -github.com/btcsuite/btcd/btcec/v2 v2.1.2/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= -github.com/btcsuite/btcd/btcec/v2 v2.1.3 h1:xM/n3yIhHAhHy04z4i43C8p4ehixJZMsnrVJkgl+MTE= -github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo= @@ -94,50 +37,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/c-bata/go-prompt v0.2.2/go.mod h1:VzqtzE2ksDBcdln8G7mk2RX9QyGjH+OVqOCSiVIqS34= -github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= -github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= -github.com/cespare/xxhash/v2 v2.1.1/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/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/cloudflare-go v0.14.0/go.mod h1:EnwdgGMaFOruiPZRFSgn+TsQ3hQ7C/YWzIGLeu5c304= -github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= -github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= -github.com/consensys/bavard v0.1.8-0.20210406032232-f3452dc9b572/go.mod h1:Bpd0/3mZuaj6Sj+PqrmIquiOKy397AKGThQPaGzNXAQ= -github.com/consensys/gnark-crypto v0.4.1-0.20210426202927-39ac3d4b3f1f/go.mod h1:815PAHg3wvysy0SyIqanF8gZ0Y1wjk/hrDHD/iT88+Q= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4= -github.com/dave/jennifer v1.2.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhrIygKg= +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= @@ -146,8 +90,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= @@ -161,249 +105,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/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= -github.com/deepmap/oapi-codegen v1.8.2/go.mod h1:YLgSKSDv/bZQB7N4ws6luhozi3cEdRktEqrX88CvjIw= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-bitstream v0.0.0-20180413035011-3522498ce2c8/go.mod h1:VMaSuZ+SZcx/wljOQKvp5srsbCiKDEb6K2wC4+PiBmQ= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= -github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/docker/docker v1.4.2-0.20180625184442-8e610b2b55bf/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/dop251/goja v0.0.0-20211011172007-d99e4b8cbf48/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= -github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= -github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= -github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= -github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= -github.com/edsrzf/mmap-go v1.0.0 h1:CEBF7HpRnUCSJgGUb5h1Gm7e3VkmVDrR8lvWVLtrOFw= -github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= -github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/ethereum/go-ethereum v1.10.17 h1:XEcumY+qSr1cZQaWsQs5Kck3FHB0V2RiMHPdTBJ+oT8= -github.com/ethereum/go-ethereum v1.10.17/go.mod h1:Lt5WzjM07XlXc95YzrhosmR4J9Ahd6X2wyEV2SvGhk0= -github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0= -github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64= -github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= -github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= -github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 h1:7HZCaLC5+BZpmbhCOZJ293Lz68O7PYrF2EzeiFMwCLk= -github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fjl/memsize v0.0.0-20190710130421-bcb5799ab5e5 h1:FtmdgXiUlNeRsoNMFlKLDt+S+6hbjVMEW6RGQ7aUf7c= -github.com/fjl/memsize v0.0.0-20190710130421-bcb5799ab5e5/go.mod h1:VvhXpOYNQvB+uIk2RvXzuaQtkQJzzIx6lSBe1xv7hi0= -github.com/flier/gorocksdb v0.0.0-20210322035443-567cc51a1652 h1:8GVjZ8n6qgX3b/0aklxpNar3RLkvS6G7FZcHkiHDUHs= -github.com/flier/gorocksdb v0.0.0-20210322035443-567cc51a1652/go.mod h1:CzkODoa0BVoE4x+tw0Pd0MOyGN/u4ip7M06gXTI7htQ= -github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= -github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= -github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= -github.com/getkin/kin-openapi v0.53.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= -github.com/getkin/kin-openapi v0.61.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= -github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= -github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= -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-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/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= -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-ole/go-ole v1.2.1 h1:2lOsA72HgjxAuMlKpFiCbHTvu44PIVkZ5hqm3RSdI/E= -github.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= -github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -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/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +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-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.3.0 h1:kHL1vqdqWNfATmA0FNMdmZNMyZI1U6O31X4rlIPoBog= -github.com/golang-jwt/jwt/v4 v4.3.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -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/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/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.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.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y= -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/flatbuffers v1.11.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -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.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.1.1-0.20200604201612-c04b05f3adfa/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -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/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.0.0/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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= -github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -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/golang-lru v0.5.5-0.20210104140557-80c98217689d/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +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/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= -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/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/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= -github.com/huin/goupnp v1.0.3-0.20220313090229-ca81a64b4204 h1:+EYBkW+dbi3F/atB+LSQZSWh7+HNrV3A/N0y6DSoy9k= -github.com/huin/goupnp v1.0.3-0.20220313090229-ca81a64b4204/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y= -github.com/huin/goutil v0.0.0-20170803182201-1ca381bf3150/go.mod h1:PpLOETDnJ0o3iZrZfqZzyLl6l7F3c6L1oWn7OICBi6o= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/influxdata/flux v0.65.1/go.mod h1:J754/zds0vvpfwuq7Gc2wRdVwEodfpCFM7mYlOw2LqY= -github.com/influxdata/influxdb v1.8.3/go.mod h1:JugdFhsvvI8gadxOI6noqNeeBHvWNTbfYGtiAn+2jhI= -github.com/influxdata/influxdb-client-go/v2 v2.4.0/go.mod h1:vLNHdxTJkIf2mSLvGrpj8TCcISApPoXkaxP8g9uRlW8= -github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= -github.com/influxdata/influxql v1.1.1-0.20200828144457-65d3ef77d385/go.mod h1:gHp9y86a/pxhjJ+zMjNXiQAA197Xk9wLxaz+fGG+kWk= -github.com/influxdata/line-protocol v0.0.0-20180522152040-32c6aa80de5e/go.mod h1:4kt73NQhadE3daL3WhR5EJ/J2ocX0PZzwxQ0gXJ7oFE= -github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= -github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= -github.com/influxdata/promql/v2 v2.12.0/go.mod h1:fxOPu+DY0bqCTCECchSRtWfc+0X19ybifQhZoQNF5D8= -github.com/influxdata/roaring v0.4.13-0.20180809181101-fc520f41fab6/go.mod h1:bSgUQ7q5ZLSO+bKBGqJiCBGAl+9DxyW63zLTujjUlOE= -github.com/influxdata/tdigest v0.0.0-20181121200506-bf2b5ad3c0a9/go.mod h1:Js0mqiSBE6Ffsg94weZZ2c+v/ciT8QRHFOap7EKDrR0= -github.com/influxdata/usage-client v0.0.0-20160829180054-6d3895376368/go.mod h1:Wbbw6tYNvwa5dlB6304Sd+82Z3f7PmVZHVKU637d4po= +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/jedisct1/go-minisign v0.0.0-20190909160543-45766022959e/go.mod h1:G1CVv03EnqU1wYL2dFwXxW2An0az9JTl/ZsqXQeBlkU= 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/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -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.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -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/jsternberg/zap-logfmt v1.0.0/go.mod h1:uvPs/4X51zdkcm5jXl5SYoN+4RK21K8mysFmDaM/h+o= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 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/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= -github.com/jwilder/encoding v0.0.0-20170811194829-b4e1701a28ef/go.mod h1:Ct9fl0F6iIOGgxJ5npU/IUOhOhqlVrGjyIZc8/MagT0= -github.com/karalabe/usb v0.0.2/go.mod h1:Od972xHfMJowv7NGVDiWVxk2zxnWgjLlJzE+F4F7AGU= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= -github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -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/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/cpuid v0.0.0-20170728055534-ae7887de9fa5/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= -github.com/klauspost/crc32 v0.0.0-20161016154125-cb6bfca970f6/go.mod h1:+ZoRqAPRLkC4NPOvfYeR5KNOrY6TD+/sAC3HXPZgDYg= -github.com/klauspost/pgzip v1.0.2-0.20170402124221-0bf5dcad4ada/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= -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/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +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/labstack/echo/v4 v4.2.1/go.mod h1:AA49e0DZ8kk5jTOOCKNuPR6oTnBS0dYiM4FW1e6jwpg= -github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= -github.com/leanovate/gopter v0.2.9/go.mod h1:U2L/78B+KVFIx2VmW6onHJQzXtFb+p5y3y2Sh+Jxxv8= -github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= -github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= -github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +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= @@ -413,521 +189,138 @@ 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/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/mattn/go-tty v0.0.0-20180907095812-13ff1204f104/go.mod h1:XPvLUNfbS4fJH25nqRHfWLMa1ONC8Amw+mIA639KxkE= -github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +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/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= -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/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= 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/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= -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/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0= -github.com/naoina/toml v0.1.2-0.20170918210437-9fafd6967416/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E= -github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= -github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= -github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k= -github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= -github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= -github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= -github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= -github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +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.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.14.0 h1:2mOpI4JVVPBN+WQRa0WKH2eXR+Ey+uK4n7Zj0aYpIQA= -github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +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.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= -github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= -github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= -github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/opentracing/opentracing-go v1.0.3-0.20180606204148-bd9c31933947/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA= -github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= -github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= -github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= -github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/paulbellamy/ratecounter v0.2.0/go.mod h1:Hfx1hDpSGoqxkVVpBi/IlYD7kChlfo5C6hzIHwPqfFE= -github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pebbe/zmq4 v1.2.1 h1:jrXQW3mD8Si2mcSY/8VBs2nNkK/sKCOEM0rHAfxyc8c= github.com/pebbe/zmq4 v1.2.1/go.mod h1:7N4y5R18zBiu3l0vajMUWQgZyjv464prE8RCyBcmnZM= -github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= -github.com/peterh/liner v1.0.1-0.20180619022028-8c1271fcf47f/go.mod h1:xIteQHvHuaLYG9IFj6mSxM0fCKrs34IrEQUhOYuGPHc= -github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= -github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= -github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= -github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -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/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.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= -github.com/pkg/term v0.0.0-20180730021639-bffc007b7fd5/go.mod h1:eCbImbZ95eXtAUIbLAuAVnBnwf83mjf6QIVH8SHYwqQ= -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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.8.0 h1:zvJNkoCFAnYFNC24FV8nW4JdRJ3GIFcLbg65lL/JDcw= -github.com/prometheus/client_golang v1.8.0/go.mod h1:O9VU6huf47PktckDQfMTX0Y8tY0/7TSWwj+ITvv0TnM= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/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.1.0/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.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= -github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.14.0 h1:RHRyE8UocrbjU+6UvRzwi6HjiDfxrrBU91TtbKzkGp4= -github.com/prometheus/common v0.14.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.2.0 h1:wH4vA7pcjKuZzjF7lM8awk4fnuJO6idemZXoKnULUx4= -github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/tsdb v0.7.1 h1:YZcsG11NqnK4czYLrWd9mpEuAJIHVQLwdrleYfszMAA= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/retailnext/hllpp v1.0.1-0.20180308014038-101a6d2f8b52/go.mod h1:RDpi1RftBQPUCDRw6SmxeaREsAaRKnOclghuzp/WRzc= -github.com/rjeczalik/notify v0.9.1 h1:CLCKso/QK1snAlnhNR/CNvNiFU2saUtjV0bx3EwNeCE= -github.com/rjeczalik/notify v0.9.1/go.mod h1:rKwnCoCGeuQnwBtTSPL9Dad03Vh2n40ePRrjvIXnJho= -github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= -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.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= +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/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/segmentio/kafka-go v0.1.0/go.mod h1:X6itGqS9L4jDletMsxZ7Dz+JFWxM6JHfPOCvTvk+EJo= -github.com/segmentio/kafka-go v0.2.0/go.mod h1:X6itGqS9L4jDletMsxZ7Dz+JFWxM6JHfPOCvTvk+EJo= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= -github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -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/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= -github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/status-im/keycard-go v0.0.0-20190316090335-8537d3370df4 h1:Gb2Tyox57NRNuZ2d3rmvB3pcmbu7O1RS3m8WRx7ilrg= -github.com/status-im/keycard-go v0.0.0-20190316090335-8537d3370df4/go.mod h1:RZLeN1LMWmRsyYjvAu+I6Dm9QmlDaIIt+Y+4Kd7Tp+Q= -github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= -github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= -github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= +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/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/testify v1.2.0/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= -github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= -github.com/syscoin/syscoinwire v1.0.2 h1:tRBfJnS6WrXYeMUvDXOsrurpn70EsdqTSGYKtJ0qeVM= -github.com/syscoin/syscoinwire v1.0.2/go.mod h1:ccEdl0E1oZneVUPaM+52A83MJZg81/vPBda1v6L5ctY= -github.com/syscoin/syscoinwire v1.0.3 h1:A7pfSXKhy72yokEHkj90z4BoVB/MQceJf738nIUSqtc= -github.com/syscoin/syscoinwire v1.0.3/go.mod h1:ccEdl0E1oZneVUPaM+52A83MJZg81/vPBda1v6L5ctY= -github.com/syscoin/syscoinwire v1.0.4 h1:GirOdtcQ5wlOpslPcGWyHgpDap6kjTNpHpxRtLy2OsQ= -github.com/syscoin/syscoinwire v1.0.4/go.mod h1:ccEdl0E1oZneVUPaM+52A83MJZg81/vPBda1v6L5ctY= -github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= -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/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/tyler-smith/go-bip39 v1.0.1-0.20181017060643-dbb3b84ba2ef h1:wHSqTBrZW24CsNJDfeh9Ex6Pm0Rcpc7qrgKBiL44vF4= -github.com/tyler-smith/go-bip39 v1.0.1-0.20181017060643-dbb3b84ba2ef/go.mod h1:sJ5fKU0s6JVwZjjcUEX2zFOnvq0ASQ2K9Zr6cf67kNs= -github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= -github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= -github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/willf/bitset v1.1.3/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/xlab/treeprint v0.0.0-20180616005107-d6fb6747feb6/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= -go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= -go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= -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.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= -go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= -go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +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/syscoin/syscoinwire v1.0.5 h1:/5EETZ0g+EpehuDZbI3uauVW+5dORjr2islN3X2tJyI= +github.com/syscoin/syscoinwire v1.0.5/go.mod h1:ccEdl0E1oZneVUPaM+52A83MJZg81/vPBda1v6L5ctY= +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-20181029021203-45a5f77698d3/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-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190909091759-094676da4a83/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-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w= -golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/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/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= -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/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.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-20181023162649-9b4f9f5ad519/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-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/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-20190125091013-d26f9f9a57f3/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-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/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-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/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-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d h1:20cMwl2fHAzkJMEA+8J4JgqBQcQGzbisXo31MIeenXI= -golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -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/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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -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-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/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-20190222072716-a9d3bda3a223/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-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/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-20191220142924-d4481acd189f/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-20200107162124-548cf772de50/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/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-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/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-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210420205809-ac73e9fd8988/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912 h1:uCLL3g5wH2xjxVREVuAbP9JM5PPKjRbXKRa6IBjkzmU= -golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.4/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 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -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-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba h1:O8mE0/t419eoIwhTFpKVkHiTs/Igowgfkj25AcZrtiE= -golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/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-20190206041539-40960b6deb8e/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-20190328211700-ab21143f2384/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-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/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-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-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200108203644-89082a384178/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -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 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= -gonum.org/v1/gonum v0.0.0-20181121035319-3f7ecaa7e8ca/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= -gonum.org/v1/gonum v0.6.0/go.mod h1:9mxDZsDKxgMAuccQkewq682L+0eCu4dCN2yonUJTCLU= -gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= -gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= -gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= -google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= -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/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -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/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-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= -google.golang.org/genproto v0.0.0-20190716160619-c506a9f90610/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -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-20200108215221-bd8f9a0ef82f/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -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.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= 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/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/olebedev/go-duktape.v3 v3.0.0-20200619000410-60c24ae608a6/go.mod h1:uAJfkITjFhyEEuUfm7bsmCZRbW5WRq8s9EY8HZ6hCns= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +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/urfave/cli.v1 v1.20.0/go.mod h1:vuBzUtMdQeixQj8LVd+/98pzhxNGQoyuPBlsXHOQNO0= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 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.2.8/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= -gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= -honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -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.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= -sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= -sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000000..1d8e04fe87 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,3118 @@ +openapi: 3.1.0 +info: + title: Blockbook API + version: "2.0.0-draft" + summary: REST and WebSocket API for indexed blockchain data served by Blockbook. + license: + name: GNU Affero General Public License v3.0 + identifier: AGPL-3.0-only + description: |- + Canonical description of the Blockbook public API, based on + blockbook-api.ts, api/xpub.go, and the api/server handlers. + + API V2 is the current Blockbook API. It is available over REST and over + WebSocket using the WsRequest/WsResponse JSON envelope documented below. + The normalized API shapes are shared by all supported coins, while + blockchain-specific payloads remain extensible where Blockbook returns raw + backend JSON. + + Amounts are strings in the lowest denomination of the chain, such as + satoshis or wei, without a decimal point. Empty fields are omitted: empty + means null, an empty string, numeric zero, a null object, or an empty array. + Since the same API serves many different chains, this can sometimes hide + otherwise meaningful zero values such as transaction version 0. + + Legacy API V1 is a Bitcore Insight-compatible subset for Bitcoin-type + coins. It is provided as-is for compatibility and is not being extended. + + Load estimates are qualitative hints for client authors. Low means a small + constant-time lookup or cached metadata. Medium means bounded indexed work + or response size. High means potentially large scans, large payloads, + broadcast/backend work, or external RPC enrichment. Actual cost also + depends on chain speed, backend health, cache warmth, mempool size, + pageSize, gap, and the number of addresses, transactions, tokens, filters, + or timestamps involved. +servers: + - url: / + description: Current Blockbook instance +tags: + - name: Status + description: Blockbook and backend status. + - name: Blocks + description: Block, block hash, raw block, and filter endpoints. + - name: Transactions + description: Transaction lookup and broadcast endpoints. + - name: Accounts + description: Address, XPUB, UTXO, and balance history endpoints. + - name: Contracts + description: Smart contract and token metadata endpoints. + - name: Fees + description: Fee estimation and fee statistics endpoints. + - name: Fiat + description: Fiat and token rate endpoints. + - name: WebSocket + description: WebSocket upgrade endpoint and message schemas. + - name: Legacy + description: Bitcore Insight-compatible V1 routes for Bitcoin-type coins. +security: [] + +paths: + /api/status: + get: + tags: [Status] + operationId: getStatus + summary: Get Blockbook and backend status. + description: |- + Returns Blockbook sync state and connected backend metadata. + + Load estimate: Low; constant-size status metadata. + responses: + "200": + description: Current Blockbook and backend status. + content: + application/json: + schema: + $ref: "#/components/schemas/SystemInfo" + examples: + status: + $ref: "#/components/examples/Status" + default: + $ref: "#/components/responses/Error" + + /api/: + get: + tags: [Status] + operationId: getApiIndexStatus + summary: Get status from the API index handler. + description: |- + Alias served by the same handler as /api/status on full public + interfaces. + + Load estimate: Low; constant-size status metadata. + responses: + "200": + description: Current Blockbook and backend status. + content: + application/json: + schema: + $ref: "#/components/schemas/SystemInfo" + examples: + status: + $ref: "#/components/examples/Status" + default: + $ref: "#/components/responses/Error" + + /api/v2/block-index/{height}: + get: + tags: [Blocks] + operationId: getBlockHashByHeight + summary: Get a block hash by height. + description: |- + Returns the block hash for a height on the backend main chain. + Blockbook follows the backend main chain; after a rollback or reorg, + height lookups resolve to the current main-chain block. + + Load estimate: Low; indexed height-to-hash lookup. + parameters: + - name: height + in: path + required: true + description: Block height on the backend main chain. + schema: + type: integer + minimum: 0 + responses: + "200": + description: Block hash at the requested height. + content: + application/json: + schema: + $ref: "#/components/schemas/BlockHashResponse" + examples: + blockHash: + $ref: "#/components/examples/BlockHash" + default: + $ref: "#/components/responses/Error" + + /api/v2/block/{blockId}: + get: + tags: [Blocks] + operationId: getBlock + summary: Get a block by height or hash. + description: |- + Returns block information with paged transactions. When full + transaction details are unavailable, the response can contain only + transaction ids. + + Blockbook follows the backend main chain. Height lookups always return + the current main-chain block. Hash lookups can return a block from + another fork only if the backend still keeps it. + + Load estimate: Medium; grows mainly with block transaction count and + requested page. + parameters: + - name: blockId + in: path + required: true + description: Block height or block hash. + schema: + type: string + - $ref: "#/components/parameters/Page" + responses: + "200": + description: Block details. + content: + application/json: + schema: + $ref: "#/components/schemas/Block" + examples: + block: + $ref: "#/components/examples/Block" + default: + $ref: "#/components/responses/Error" + + /api/v2/rawblock/{blockId}: + get: + tags: [Blocks] + operationId: getRawBlock + summary: Get raw block hex. + description: |- + Returns raw serialized block data. + + Load estimate: High for large blocks; payload size grows with the raw + block size. + parameters: + - name: blockId + in: path + required: true + description: Block height or block hash. + schema: + type: string + responses: + "200": + description: Raw block data. + content: + application/json: + schema: + $ref: "#/components/schemas/BlockRaw" + examples: + rawBlock: + $ref: "#/components/examples/RawBlock" + default: + $ref: "#/components/responses/Error" + + /api/v2/block-filters/: + get: + tags: [Blocks] + operationId: getBlockFilters + summary: Get compact block filters. + description: |- + Returns compact block filters for the script type configured on this + Blockbook instance. Provide either lastN or a from/to range. When to is + omitted for a range, the current best height is used. + + Load estimate: High for wide ranges; work and payload grow linearly + with the number of requested filters. + parameters: + - name: scriptType + in: query + required: true + description: Script type configured for block filters on this instance, for example taproot. + schema: + type: string + - name: lastN + in: query + description: Return filters for the last N blocks. + schema: + type: integer + minimum: 1 + - name: from + in: query + description: First block height in the requested range. + schema: + type: integer + minimum: 0 + - name: to + in: query + description: Last block height in the requested range. Defaults to the current best height when omitted. + schema: + type: integer + minimum: 0 + responses: + "200": + description: Block filters keyed by block height. + content: + application/json: + schema: + $ref: "#/components/schemas/BlockFilters" + examples: + blockFilters: + $ref: "#/components/examples/BlockFilters" + default: + $ref: "#/components/responses/Error" + + /api/v2/tx/{txid}: + get: + tags: [Transactions] + operationId: getTransaction + summary: Get a normalized transaction. + description: |- + Returns normalized transaction data with the same general structure for + all supported coins. Coin-specific fields that do not fit the common + shape are omitted here; use getTransactionSpecific for backend-native + JSON. + + Bitcoin-like confirmed transactions include blockHash, confirmations, + blockTime, size/vsize, value/valueIn, fees, and hex. Unconfirmed + transactions can include confirmationETABlocks and + confirmationETASeconds. + + Ethereum-like transactions have one vin and one vout, tokenTransfers, + ethereumSpecific execution data, and optional addressAliases. Parsed + input data is included when the 4byte signature can be resolved. + + For mined transactions, blockTime is the block timestamp. For mempool + transactions, blockTime is when this Blockbook instance first learned + about the transaction and can differ between instances. + + Load estimate: Medium; grows with inputs, outputs, token transfers, + address aliases, and spending=true extra lookups. + parameters: + - name: txid + in: path + required: true + description: Transaction id/hash. + schema: + type: string + - name: spending + in: query + description: Include spending transaction metadata for UTXO outputs when available. + schema: + type: boolean + responses: + "200": + description: Normalized transaction. + content: + application/json: + schema: + $ref: "#/components/schemas/Tx" + examples: + bitcoinConfirmed: + $ref: "#/components/examples/BitcoinTransactionConfirmed" + bitcoinUnconfirmed: + $ref: "#/components/examples/BitcoinTransactionUnconfirmed" + ethereum: + $ref: "#/components/examples/EthereumTransaction" + default: + $ref: "#/components/responses/Error" + + /api/v2/tx-specific/{txid}: + get: + tags: [Transactions] + operationId: getTransactionSpecific + summary: Get blockchain-specific transaction JSON. + description: |- + Returns transaction data in the exact backend-specific format. Use this + when a chain exposes fields that are intentionally absent from the + normalized Tx schema. + + Load estimate: Medium; payload size depends on chain-specific fields + and transaction complexity. + parameters: + - name: txid + in: path + required: true + description: Transaction id/hash. + schema: + type: string + responses: + "200": + description: Chain-specific transaction payload. + content: + application/json: + schema: + description: Arbitrary chain-specific transaction payload. + examples: + transactionSpecific: + $ref: "#/components/examples/TransactionSpecific" + default: + $ref: "#/components/responses/Error" + + /api/rawtx/{txid}: + get: + tags: [Transactions] + operationId: getRawTransaction + summary: Get raw transaction hex. + description: |- + Unversioned public endpoint exposed by Blockbook for raw transaction + data. + + Load estimate: Medium; payload size grows with raw transaction size. + parameters: + - name: txid + in: path + required: true + description: Transaction id/hash. + schema: + type: string + responses: + "200": + description: Raw transaction hex as a JSON string. + content: + application/json: + schema: + type: string + examples: + rawTransaction: + $ref: "#/components/examples/RawTransaction" + default: + $ref: "#/components/responses/Error" + + /api/v2/sendtx/{hex}: + get: + tags: [Transactions] + operationId: sendTransactionByGet + summary: Broadcast a raw transaction using the path. + description: |- + Broadcasts hex-encoded raw transaction data. Prefer POST for large + payloads. + + Load estimate: High; validates and forwards to the backend, with cost + growing with transaction size and backend mempool policy checks. + parameters: + - name: hex + in: path + required: true + description: Raw transaction. + schema: + type: string + pattern: "^[0-9a-fA-F]+$" + responses: + "200": + description: Broadcast result. + content: + application/json: + schema: + $ref: "#/components/schemas/SendTransactionResponse" + examples: + broadcast: + $ref: "#/components/examples/SendTransaction" + default: + $ref: "#/components/responses/Error" + + /api/v2/sendtx/: + post: + tags: [Transactions] + operationId: sendTransactionByPost + summary: Broadcast a raw transaction using the request body. + description: |- + Broadcasts hex-encoded raw transaction data from the request body. The + trailing slash is mandatory in the Blockbook handler. POST bodies are + limited to 8 MiB. + + Load estimate: High; validates and forwards to the backend, with cost + growing with transaction size and backend mempool policy checks. + requestBody: + required: true + content: + text/plain: + schema: + type: string + pattern: "^[0-9a-fA-F]+$" + responses: + "200": + description: Broadcast result. + content: + application/json: + schema: + $ref: "#/components/schemas/SendTransactionResponse" + examples: + broadcast: + $ref: "#/components/examples/SendTransaction" + default: + $ref: "#/components/responses/Error" + + /api/v2/address/{address}: + get: + tags: [Accounts] + operationId: getAddress + summary: Get address/account details. + description: |- + Returns balances and transactions of an address. Transactions are + sorted by block height with newest blocks first. Response size is + controlled by the details parameter. + + At details=basic, mempool transactions are not aggregated: + unconfirmedBalance, unconfirmedSending, and unconfirmedReceiving are + omitted, and unconfirmedTxs reports the raw mempool index size for the + address. + + Load estimate: Variable; basic is low, token/tokenBalances and + txids/txslight are medium, and txs can be high as it grows with + pageSize, transactions, token rows, filters, and protocol enrichment. + parameters: + - name: address + in: path + required: true + description: Chain address. + schema: + type: string + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + - $ref: "#/components/parameters/FromHeight" + - $ref: "#/components/parameters/ToHeight" + - $ref: "#/components/parameters/Details" + - $ref: "#/components/parameters/Filter" + - $ref: "#/components/parameters/ContractFilter" + - $ref: "#/components/parameters/Protocols" + - $ref: "#/components/parameters/SecondaryCurrency" + - $ref: "#/components/parameters/ConfirmedNonce" + responses: + "200": + description: Address/account details. + content: + application/json: + schema: + $ref: "#/components/schemas/Address" + examples: + bitcoinTxids: + $ref: "#/components/examples/BitcoinAddressTxids" + ethereumTokenBalances: + $ref: "#/components/examples/EthereumAddressTokenBalances" + default: + $ref: "#/components/responses/Error" + + /api/v2/xpub/{xpub}: + get: + tags: [Accounts] + operationId: getXpub + summary: Get XPUB or descriptor account details. + description: |- + Returns balances and transactions of an XPUB or output descriptor for + Bitcoin-type coins. Transactions are sorted by block height with newest + blocks first. URL-encode descriptors before placing them after /xpub/. + + Blockbook expects XPUBs at level 3 of the derivation path, for example + m/purpose'/coin_type'/account'. It derives the remaining + change/address_index path. The BIP scheme is inferred from the XPUB + prefix; unknown prefixes default to BIP44. + + Supported descriptors are pkh(xpub), sh(wpkh(xpub)), wpkh(xpub), and + tr(xpub). Descriptors can include origin paths and change selectors + such as <0;1> or {0,1}; when change is omitted, Blockbook defaults to + <0;1>. + + Note: usedTokens always reports the total number of used addresses for + the XPUB, regardless of the tokens query filter. + + Load estimate: High for broad accounts; grows with derived addresses, + gap, used address count, pageSize, transaction history, token rows, and + protocol enrichment. + parameters: + - name: xpub + in: path + required: true + allowReserved: true + description: XPUB or supported descriptor. + schema: + type: string + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PageSize" + - $ref: "#/components/parameters/FromHeight" + - $ref: "#/components/parameters/ToHeight" + - $ref: "#/components/parameters/Details" + - $ref: "#/components/parameters/Tokens" + - $ref: "#/components/parameters/Filter" + - $ref: "#/components/parameters/ContractFilter" + - $ref: "#/components/parameters/Protocols" + - $ref: "#/components/parameters/SecondaryCurrency" + - $ref: "#/components/parameters/Gap" + responses: + "200": + description: XPUB/descriptor account details. + content: + application/json: + schema: + $ref: "#/components/schemas/Address" + examples: + xpub: + $ref: "#/components/examples/XpubAddress" + default: + $ref: "#/components/responses/Error" + + /api/v2/utxo/{descriptor}: + get: + tags: [Accounts] + operationId: getUtxo + summary: Get UTXOs for an address, XPUB, or descriptor. + description: |- + Returns unspent outputs for an address, XPUB, or descriptor on + Bitcoin-type coins. By default both confirmed and unconfirmed UTXOs are + returned; confirmed=true filters out unconfirmed entries. Results are + sorted by block height with newest entries first. + + Unconfirmed UTXOs omit height, have confirmations set to 0, and can + include lockTime. XPUB and descriptor UTXOs also include address and + derivation path when available. Coinbase UTXOs include coinbase=true + only up to the coinbase confirmation limit, currently 100 blocks. + + Load estimate: Variable; address lookups are usually medium, while + XPUB/descriptor lookups grow with gap, derived addresses, and UTXO + count. + parameters: + - name: descriptor + in: path + required: true + allowReserved: true + description: Address, XPUB, or supported descriptor. URL-encode descriptors. + schema: + type: string + - name: confirmed + in: query + description: When true, return only confirmed UTXOs. + schema: + type: boolean + - $ref: "#/components/parameters/Gap" + responses: + "200": + description: UTXO list. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Utxo" + examples: + utxos: + $ref: "#/components/examples/UtxoList" + default: + $ref: "#/components/responses/Error" + + /api/v2/balancehistory/{descriptor}: + get: + tags: [Accounts] + operationId: getBalanceHistory + summary: Get account balance history. + description: |- + Returns balance history points for an address, XPUB, or descriptor. + from and to are Unix timestamps. groupBy is an aggregation interval in + seconds and defaults to 3600. When fiatcurrency is omitted, rates can + contain all available currencies. sentToSelf is the amount sent from an + address to itself or within addresses of the same XPUB. + + Load estimate: High; grows with account transaction history, time span, + grouping cardinality, fiat rate lookups, and XPUB/descriptor gap. + parameters: + - name: descriptor + in: path + required: true + allowReserved: true + description: Address, XPUB, or supported descriptor. URL-encode descriptors. + schema: + type: string + - name: from + in: query + description: Unix timestamp lower bound. + schema: + type: integer + format: int64 + - name: to + in: query + description: Unix timestamp upper bound. + schema: + type: integer + format: int64 + - name: fiatcurrency + in: query + description: Optional fiat currency code to include in rates. + schema: + type: string + example: usd + - name: groupBy + in: query + description: Aggregation interval in seconds. Defaults to 3600. + schema: + type: integer + minimum: 1 + - $ref: "#/components/parameters/Gap" + responses: + "200": + description: Balance history points. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/BalanceHistory" + examples: + allRates: + $ref: "#/components/examples/BalanceHistoryAllRates" + usd: + $ref: "#/components/examples/BalanceHistoryUsd" + grouped: + $ref: "#/components/examples/BalanceHistoryGrouped" + default: + $ref: "#/components/responses/Error" + + /api/v2/contract/{contract}: + get: + tags: [Contracts] + operationId: getContractInfo + summary: Get contract metadata. + description: |- + Returns indexed token/contract metadata and optional current protocol + enrichments such as ERC4626. + + blockHeight reflects the indexer's best block at request time. + ERC4626 fields under protocols.erc4626 are fetched through JSON-RPC + calls pinned to that exact blockHeight, so the ERC4626 values are a + consistent snapshot. If a vault is detected but the underlying asset + metadata cannot be resolved, protocols.erc4626 contains error and omits + asset; callers must not derive fiat rates or human-unit exchange rates + from such a partial response. + + Load estimate: Medium; indexed metadata is cheap, but optional protocol + enrichment can add backend RPC calls and token metadata lookups. + parameters: + - name: contract + in: path + required: true + description: Smart contract address. + schema: + type: string + - name: currency + in: query + description: Secondary currency code for rates. + schema: + type: string + example: usd + - $ref: "#/components/parameters/Protocols" + responses: + "200": + description: Contract metadata. + content: + application/json: + schema: + $ref: "#/components/schemas/ContractInfoResult" + examples: + contract: + $ref: "#/components/examples/ContractInfo" + default: + $ref: "#/components/responses/Error" + + /api/v2/estimatefee/{blocks}: + get: + tags: [Fees] + operationId: estimateFee + summary: Estimate a fee target. + description: |- + Returns backend fee estimation for the requested confirmation target. + + Load estimate: Low; a small backend fee estimate lookup. + parameters: + - name: blocks + in: path + required: true + description: Confirmation target in blocks. + schema: + type: integer + minimum: 1 + - name: conservative + in: query + description: Use conservative smart fee estimation where supported. + schema: + type: boolean + default: true + responses: + "200": + description: Decimal fee estimate in chain base currency. + content: + application/json: + schema: + $ref: "#/components/schemas/ResultStringResponse" + examples: + estimateFee: + $ref: "#/components/examples/EstimateFee" + default: + $ref: "#/components/responses/Error" + + /api/v2/feestats/{blockId}: + get: + tags: [Fees] + operationId: getFeeStats + summary: Get fee statistics for a block. + description: |- + Returns fee statistics for transactions in one block. + + Load estimate: Medium to high; grows with the number of transactions + in the requested block. + parameters: + - name: blockId + in: path + required: true + description: Block height or block hash. + schema: + type: string + responses: + "200": + description: Fee statistics. + content: + application/json: + schema: + $ref: "#/components/schemas/FeeStats" + examples: + feeStats: + $ref: "#/components/examples/FeeStats" + default: + $ref: "#/components/responses/Error" + + /api/v2/tickers/: + get: + tags: [Fiat] + operationId: getFiatTicker + summary: Get current or historical fiat rates. + description: |- + Returns currency rates for the requested currency and date. If a rate + is unavailable for the exact timestamp, the closest available rate can + be returned. Responses include the actual rate timestamp. Without a + currency parameter, all available currencies can be returned. A rate of + -1 marks an unavailable or invalid currency for that timestamp. + + Load estimate: Low to medium; specific currency lookups are cheap, + while omitted currency and token lookups increase response size. + parameters: + - name: currency + in: query + description: Optional currency code. When omitted, all available rates can be returned. + schema: + type: string + example: usd + - name: timestamp + in: query + description: Unix timestamp for historical rates. + schema: + type: integer + format: int64 + - name: block + in: query + description: Block height or hash whose timestamp should be used for historical rates. + schema: + type: string + - name: token + in: query + description: Optional token symbol or contract/address key for token-specific rates. + schema: + type: string + responses: + "200": + description: Fiat rate ticker. + content: + application/json: + schema: + $ref: "#/components/schemas/FiatTicker" + examples: + allRates: + $ref: "#/components/examples/FiatTickerAll" + usd: + $ref: "#/components/examples/FiatTickerUsd" + unavailable: + $ref: "#/components/examples/FiatTickerUnavailable" + default: + $ref: "#/components/responses/Error" + + /api/v2/multi-tickers/: + get: + tags: [Fiat] + operationId: getFiatTickersForTimestamps + summary: Get fiat rates for multiple timestamps. + description: |- + Returns fiat rate tickers for a comma-separated list of Unix + timestamps. + + Load estimate: Medium; work and payload grow linearly with timestamp + count, plus token/currency selection. + parameters: + - name: timestamp + in: query + required: true + description: Comma-separated Unix timestamps. + schema: + type: string + pattern: "^[0-9]+(,[0-9]+)*$" + example: "1710000000,1720000000" + - name: currency + in: query + description: Optional currency code. + schema: + type: string + example: usd + - name: token + in: query + description: Optional token symbol or contract/address key. + schema: + type: string + responses: + "200": + description: Fiat rate tickers. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/FiatTicker" + examples: + multiTickers: + $ref: "#/components/examples/MultiTickers" + default: + $ref: "#/components/responses/Error" + + /api/v2/tickers-list/: + get: + tags: [Fiat] + operationId: getFiatTickersList + summary: Get currencies available for a timestamp. + description: |- + Returns available secondary currencies for a date together with the + actual rate timestamp. + + Load estimate: Low to medium; token lookups and wide currency lists + increase response size. + parameters: + - name: timestamp + in: query + required: true + description: Unix timestamp for the requested currency list. + schema: + type: integer + format: int64 + - name: token + in: query + description: Optional token symbol or contract/address key. + schema: + type: string + responses: + "200": + description: Available currencies. + content: + application/json: + schema: + $ref: "#/components/schemas/AvailableVsCurrencies" + examples: + tickersList: + $ref: "#/components/examples/TickersList" + default: + $ref: "#/components/responses/Error" + + /api/v1/block-index/{height}: + get: + tags: [Legacy] + operationId: getLegacyBlockHashByHeight + summary: Legacy get block hash by height. + description: |- + Bitcore Insight-compatible V1 route for Bitcoin-type coins. + + Load estimate: Low; indexed height-to-hash lookup. + parameters: + - name: height + in: path + required: true + description: Block height on the backend main chain. + schema: + type: integer + minimum: 0 + responses: + "200": + description: Block hash at the requested height. + content: + application/json: + schema: + $ref: "#/components/schemas/BlockHashResponse" + examples: + blockHash: + $ref: "#/components/examples/BlockHash" + default: + $ref: "#/components/responses/Error" + + /api/v1/tx/{txid}: + get: + tags: [Legacy] + operationId: getLegacyTransaction + summary: Legacy get transaction. + description: |- + Bitcore Insight-compatible V1 transaction shape for Bitcoin-type + coins. + + Load estimate: Medium; grows with inputs, outputs, scripts, and raw + transaction size. + parameters: + - name: txid + in: path + required: true + description: Transaction id/hash. + schema: + type: string + responses: + "200": + description: Legacy transaction payload. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyObject" + default: + $ref: "#/components/responses/Error" + + /api/v1/address/{address}: + get: + tags: [Legacy] + operationId: getLegacyAddress + summary: Legacy get address. + description: |- + Bitcore Insight-compatible V1 address shape for Bitcoin-type coins. + + Load estimate: Variable; grows with address transaction count and + legacy response expansion. + parameters: + - name: address + in: path + required: true + description: Chain address. + schema: + type: string + responses: + "200": + description: Legacy address payload. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyObject" + default: + $ref: "#/components/responses/Error" + + /api/v1/utxo/{address}: + get: + tags: [Legacy] + operationId: getLegacyUtxo + summary: Legacy get address UTXOs. + description: |- + Bitcore Insight-compatible V1 UTXO list for Bitcoin-type coins. + + Load estimate: Medium; grows with the number of unspent outputs. + parameters: + - name: address + in: path + required: true + description: Chain address. + schema: + type: string + responses: + "200": + description: Legacy UTXO list. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/LegacyObject" + default: + $ref: "#/components/responses/Error" + + /api/v1/block/{blockId}: + get: + tags: [Legacy] + operationId: getLegacyBlock + summary: Legacy get block by height or hash. + description: |- + Bitcore Insight-compatible V1 block shape for Bitcoin-type coins. + + Load estimate: Medium to high; grows with block transaction count and + legacy payload size. + parameters: + - name: blockId + in: path + required: true + description: Block height or block hash. + schema: + type: string + responses: + "200": + description: Legacy block payload. + content: + application/json: + schema: + $ref: "#/components/schemas/LegacyObject" + default: + $ref: "#/components/responses/Error" + + /api/v1/estimatefee/{blocks}: + get: + tags: [Legacy] + operationId: estimateLegacyFee + summary: Legacy estimate fee target. + description: |- + Bitcore Insight-compatible V1 fee estimate for Bitcoin-type coins. + + Load estimate: Low; a small backend fee estimate lookup. + parameters: + - name: blocks + in: path + required: true + description: Confirmation target in blocks. + schema: + type: integer + minimum: 1 + responses: + "200": + description: Decimal fee estimate in chain base currency. + content: + application/json: + schema: + $ref: "#/components/schemas/ResultStringResponse" + examples: + estimateFee: + $ref: "#/components/examples/EstimateFee" + default: + $ref: "#/components/responses/Error" + + /api/v1/sendtx/{hex}: + get: + tags: [Legacy] + operationId: sendLegacyTransactionByGet + summary: Legacy broadcast transaction using the path. + description: |- + Bitcore Insight-compatible V1 broadcast route for Bitcoin-type coins. + + Load estimate: High; validates and forwards to the backend, with cost + growing with transaction size and backend mempool policy checks. + parameters: + - name: hex + in: path + required: true + description: Raw transaction. + schema: + type: string + pattern: "^[0-9a-fA-F]+$" + responses: + "200": + description: Broadcast result. + content: + application/json: + schema: + $ref: "#/components/schemas/SendTransactionResponse" + examples: + broadcast: + $ref: "#/components/examples/SendTransaction" + default: + $ref: "#/components/responses/Error" + + /api/v1/sendtx/: + post: + tags: [Legacy] + operationId: sendLegacyTransactionByPost + summary: Legacy broadcast transaction using the request body. + description: |- + Bitcore Insight-compatible V1 broadcast route for Bitcoin-type coins. + + Load estimate: High; validates and forwards to the backend, with cost + growing with transaction size and backend mempool policy checks. + requestBody: + required: true + content: + text/plain: + schema: + type: string + pattern: "^[0-9a-fA-F]+$" + responses: + "200": + description: Broadcast result. + content: + application/json: + schema: + $ref: "#/components/schemas/SendTransactionResponse" + examples: + broadcast: + $ref: "#/components/examples/SendTransaction" + default: + $ref: "#/components/responses/Error" + + /websocket: + get: + tags: [WebSocket] + operationId: connectWebSocket + summary: WebSocket upgrade endpoint. + description: |- + Connect with a WebSocket client and exchange JSON messages using the + WsRequest/WsResponse schemas. The endpoint can also be explored through + /test-websocket.html on a Blockbook instance. + + During a backend reorg, new-block notifications can contain a block + hash at the same or even smaller height. + + Load estimate: Variable; idle connections are low, request methods + cost roughly like their REST equivalents, and subscriptions grow with + connection count, subscribed addresses, and event frequency. + servers: + - url: / + description: Current Blockbook instance. Use ws or wss with the current host for WebSocket clients. + responses: + "101": + description: WebSocket protocol upgrade. + "400": + description: Bad WebSocket upgrade request. + x-websocket-request: + $ref: "#/components/schemas/WsRequest" + x-websocket-response: + $ref: "#/components/schemas/WsResponse" + x-websocket-examples: + getInfo: + $ref: "#/components/examples/WebSocketGetInfoRequest" + subscribeAddresses: + $ref: "#/components/examples/WebSocketSubscribeAddressesRequest" + getContractInfo: + $ref: "#/components/examples/WebSocketGetContractInfoRequest" + getBlock: + $ref: "#/components/examples/WebSocketGetBlockRequest" + +components: + parameters: + Page: + name: page + in: query + description: 1-based page index. Values outside safe bounds are sanitized to the closest possible page. + schema: + type: integer + minimum: 1 + PageSize: + name: pageSize + in: query + description: Number of history items per page. The default and maximum for REST account endpoints is 1000. + schema: + type: integer + minimum: 1 + maximum: 1000 + FromHeight: + name: from + in: query + description: First block height included in account transaction filtering. + schema: + type: integer + minimum: 0 + ToHeight: + name: to + in: query + description: Last block height included in account transaction filtering. + schema: + type: integer + minimum: 0 + Details: + name: details + in: query + description: |- + Controls how much account data is returned. + basic returns balances and counts only. + tokens adds known token rows. + tokenBalances returns token rows with balances. + txids adds paged transaction ids. + txslight adds limited transaction details from the index. + txs adds full transaction details. + schema: + type: string + default: txids + enum: [basic, tokens, tokenBalances, txids, txslight, txs] + Tokens: + name: tokens + in: query + description: |- + Controls which XPUB-derived address rows are included: nonzero returns + only addresses with nonzero balance, used returns addresses with at + least one transaction, and derived returns all derived addresses. + schema: + type: string + default: nonzero + enum: [nonzero, used, derived] + Filter: + name: filter + in: query + description: Filter account history by input/output side, or by numeric token/internal filter id. + schema: + oneOf: + - type: string + enum: [inputs, outputs] + - type: integer + minimum: 0 + ContractFilter: + name: contract + in: query + description: Contract address used to filter token data. + schema: + type: string + Protocols: + name: protocols + in: query + description: "Optional protocol enrichments, comma-separated or repeated. Currently supported value: erc4626. Unknown values are rejected." + style: form + explode: false + schema: + type: array + items: + type: string + example: erc4626 + SecondaryCurrency: + name: secondary + in: query + description: Secondary currency code used to populate fiat values. + schema: + type: string + example: usd + Gap: + name: gap + in: query + description: XPUB/address derivation gap limit. Values are capped by the server. + schema: + type: integer + minimum: 0 + maximum: 10000 + ConfirmedNonce: + name: confirmedNonce + in: query + description: |- + If true, additionally return the confirmed nonce for Ethereum-like + addresses (the confirmedNonce response field). This triggers an extra + eth_getTransactionCount("latest") backend call, so it is off by default. + schema: + type: boolean + + responses: + Error: + description: Public API error. Public validation errors are HTTP 400; internal errors are HTTP 500. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + error: + $ref: "#/components/examples/Error" + + examples: + Error: + summary: REST error + value: + error: "Transaction 'missing-txid' not found" + + Status: + summary: Blockbook and backend status + value: + blockbook: + coin: Bitcoin + 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: 860730 + lastBlockTime: "2024-09-10T08:19:04.471017534Z" + inSyncMempool: true + lastMempoolTime: "2024-09-10T08:42:39.38871351Z" + mempoolSize: 232021 + decimals: 8 + dbSize: 761283489075 + hasFiatRates: true + currentFiatRatesTime: "2024-09-10T08:42:00.898792419Z" + historicalFiatRatesTime: "2024-09-10T00:00:00Z" + about: Blockbook - blockchain indexer for Trezor Suite. + backend: + chain: main + blocks: 860730 + headers: 860730 + bestBlockHash: "00000000000000000000effeb0c4460480e6a347deab95332c63007a68646ee5" + difficulty: "89471664776970.77" + sizeOnDisk: 681584532221 + version: "270100" + subversion: "/Satoshi:27.1.0/" + protocolVersion: "70016" + + BlockHash: + summary: Block hash at height + value: + blockHash: "0000000000000000000b7b8574bc6fd285825ec2dbcbeca149121fc05b0c828c" + + RawBlock: + summary: Raw block + value: + hex: "00000020f3e9..." + + BlockFilters: + summary: Compact block filters + value: + P: 19 + M: 784931 + zeroedKey: false + blockFilters: + "860730": + blockHash: "00000000000000000000effeb0c4460480e6a347deab95332c63007a68646ee5" + filter: "0286f0..." + + BitcoinTransactionConfirmed: + summary: Bitcoin-like confirmed transaction + value: + txid: "8c1e3dec662d1f2a5e322ccef5eca263f98eb16723c6f990be0c88c1db113fb1" + version: 2 + lockTime: 860729 + vin: + - txid: "0eb7b574373de2c88d0dc1444f49947c681d0437d21361f9ebb4dd09c62f2a66" + vout: 1 + sequence: 4294967293 + n: 0 + addresses: ["bc1qmgwnfjlda4ns3g6g3yz74w6scnn9yu2ts82yyc"] + isAddress: true + value: "10106300" + vout: + - value: "175000" + n: 0 + hex: "76a914ecc999d554eaa3efa5e871c28f58b549c36ec51788ac" + addresses: ["1Nb1ykSD7J5k4RFjJQGsrD9gxBE6jzfNa9"] + isAddress: true + - value: "9888100" + n: 1 + hex: "001496f152a0919487624bf4f13f46f0d20fa10d9acc" + addresses: ["bc1qjmc49gy3jjrkyjl57yl5duxjp7ssmxkvh5t2q5"] + isAddress: true + blockHash: "00000000000000000000effeb0c4460480e6a347deab95332c63007a68646ee5" + blockHeight: 860730 + confirmations: 1 + blockTime: 1725956288 + size: 225 + vsize: 144 + value: "10063100" + valueIn: "10106300" + fees: "43200" + hex: "02000000000101662a..." + + BitcoinTransactionUnconfirmed: + summary: Bitcoin-like unconfirmed transaction + value: + txid: "73b1ad97194e426031e5c692869de2d83dc2ff6033fc6f0ab5514345f92eaf0d" + version: 2 + vin: + - txid: "bccbebb64b1613ada74eefa96753088a80fefa53a10e42c66eef1899371bc096" + n: 0 + addresses: ["bc1q9lh77es6m8ztr7muwcec00ewn8fxakpl9jwv8y"] + isAddress: true + value: "371042" + vout: + - value: "293135" + n: 0 + hex: "0014aafd7386f99f4b508ec05ee8f7edc2e07126620a" + addresses: ["bc1q4t7h8phena94prkqtm500mwzupcjvcs2akcdy9"] + isAddress: true + blockHeight: -1 + confirmations: 0 + confirmationETABlocks: 1 + confirmationETASeconds: 619 + blockTime: 1725959035 + size: 222 + vsize: 141 + value: "367157" + valueIn: "371042" + fees: "3885" + rbf: true + + EthereumTransaction: + summary: Ethereum-like transaction + value: + txid: "0xa6c8ae1f91918d09cf2bd67bbac4c168849e672fd81316fa1d26bb9b4fc0f790" + vin: + - n: 0 + addresses: ["0xd446089cf19C3D3Eb1743BeF3A852293Fd2C7775"] + isAddress: true + vout: + - value: "5615959129349132871" + n: 0 + addresses: ["0xC36442b4a4522E871399CD717aBDD847Ab11FE88"] + isAddress: true + blockHash: "0x10ea8cfecda89d6d864c1d919911f819c9febc2b455b48c9918cee3c6cdc4adb" + blockHeight: 16529834 + confirmations: 3 + blockTime: 1675204631 + value: "5615959129349132871" + fees: "19141662404282012" + tokenTransfers: + - type: ERC20 + standard: ERC20 + from: "0xd446089cf19C3D3Eb1743BeF3A852293Fd2C7775" + to: "0x3B685307C8611AFb2A9E83EBc8743dc20480716E" + contract: "0x4E15361FD6b4BB609Fa63C81A2be19d873717870" + name: Fantom Token + symbol: FTM + decimals: 18 + value: "15362368338194882707417" + ethereumSpecific: + status: 1 + nonce: 505 + gasLimit: 550941 + gasUsed: 434686 + gasPrice: "44035608242" + maxPriorityFeePerGas: "44035608243" + maxFeePerGas: "44035608244" + baseFeePerGas: "2035608244" + data: "0xac9650d800000000000000000000" + parsedData: + methodId: "0xfa2b068f" + name: Mint + function: "mint(address, uint256, uint32, bytes32[], address)" + params: + - type: address + values: ["0xa5fD1Da088598e88ba731B0E29AECF0BC2A31F82"] + internalTransfers: + - type: 0 + from: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88" + to: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + value: "5615959129349132871" + addressAliases: + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": + Type: Contract + Alias: Wrapped Ether + + TransactionSpecific: + summary: Backend-native transaction JSON + value: + hex: "040000808...8e6e73cb009" + txid: "7a0a0ff6f67bac2a856c7296382b69151949878de6fb0d01a8efa197182b2913" + size: 1809 + overwintered: true + version: 4 + versiongroupid: "892f2085" + locktime: 0 + expiryheight: 495680 + vin: [] + vout: [] + blockhash: "0000000001c4aa394e796dd1b82e358f114535204f6f5b6cf4ad58dc439c47af" + height: 495665 + confirmations: 2145803 + time: 1552301566 + blocktime: 1552301566 + + RawTransaction: + summary: Raw transaction hex + value: "02000000000101662a2fc609ddb4eb..." + + SendTransaction: + summary: Broadcast result + value: + result: "7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25" + + BitcoinAddressTxids: + summary: Address with transaction ids + value: + page: 1 + totalPages: 1 + itemsOnPage: 1000 + address: "bc1q0wd209cv5k9pd9mhk7nspacywcj038xxdhnt5u" + balance: "4225100" + totalReceived: "4225100" + totalSent: "0" + unconfirmedBalance: "0" + unconfirmedTxs: 0 + txs: 2 + txids: + - "0db6010dc0815a4bdaa505bd1ccc851056b0d53c7e4ea7af39c4d648a2c0c019" + - "7532920ddc506218337cceac978cce9c7f98e27ad3226dee55f3e934e0b32e80" + + EthereumAddressTokenBalances: + summary: Ethereum address with token balances and secondary currency + value: + address: "0x2df3951b2037bA620C20Ed0B73CCF45Ea473e83B" + balance: "21004631949601199" + unconfirmedBalance: "0" + unconfirmedTxs: 0 + txs: 5 + nonTokenTxs: 3 + nonce: "1" + tokens: + - type: ERC20 + standard: ERC20 + name: Tether USD + contract: "0xdAC17F958D2ee523a2206206994597C13D831ec7" + transfers: 3 + symbol: USDT + decimals: 6 + balance: "4913000000" + baseValue: 3.104622978658881 + secondaryValue: 4914.214559070491 + secondaryValue: 33.247601671503574 + tokensBaseValue: 3.104622978658881 + tokensSecondaryValue: 4914.214559070491 + totalBaseValue: 3.125627610608482 + totalSecondaryValue: 4947.462160741995 + + ContractInfo: + summary: Contract metadata with ERC4626 enrichment + value: + type: ERC20 + standard: ERC20 + contract: "0x0000000000000000000000000000000000000001" + name: Vault Share + symbol: vETH + decimals: 18 + rates: + baseRate: 0.000523 + currency: usd + secondaryRate: 1.24 + protocols: + erc4626: + asset: + contract: "0x0000000000000000000000000000000000000002" + name: Wrapped Ether + symbol: WETH + decimals: 18 + share: + contract: "0x0000000000000000000000000000000000000001" + name: Vault Share + symbol: vETH + decimals: 18 + totalAssets: "123456789" + convertToAssets1Share: "1000000000000000000" + convertToShares1Asset: "1000000000000000000" + previewDeposit1Asset: "999999999999999999" + previewRedeem1Share: "1000000000000000000" + blockHeight: 12345678 + + XpubAddress: + summary: XPUB account + value: + page: 1 + totalPages: 1 + itemsOnPage: 1000 + address: "dgub8sbe5Mi8LA4dXB9zPfLZW8arm...9Vjp2HHx91xdDEmWYpmD49fpoUYF" + balance: "90000000" + totalReceived: "3093381250" + totalSent: "3083381250" + unconfirmedBalance: "0" + unconfirmedTxs: 0 + txs: 5 + txids: + - "383ccb5da16fccad294e24a2ef77bdee5810573bb1b252d8b2af4f0ac8c4e04c" + usedTokens: 2 + tokens: + - type: XPUBAddress + standard: XPUBAddress + name: DUCd1B3YBiXL5By15yXgSLZtEkvwsgEdqS + path: "m/44'/3'/0'/0/0" + transfers: 3 + decimals: 8 + balance: "90000000" + totalReceived: "2903986975" + totalSent: "2803986975" + secondaryValue: 21195.47633568 + + UtxoList: + summary: UTXOs + value: + - txid: "13d26cd939bf5d155b1c60054e02d9c9b832a85e6ec4f2411be44b6b5a2842e9" + vout: 0 + value: "1422303206539" + confirmations: 0 + lockTime: 2648100 + - txid: "a79e396a32e10856c97b95f43da7e9d2b9a11d446f7638dbd75e5e7603128cac" + vout: 1 + value: "39748685" + height: 2648043 + confirmations: 47 + coinbase: true + + Block: + summary: Block with transactions + value: + page: 1 + totalPages: 1 + itemsOnPage: 1000 + hash: "760f8ed32894ccce9c1ea11c8a019cadaa82bcb434b25c30102dd7e43f326217" + previousBlockHash: "786a1f9f38493d32fd9f9c104d748490a070bc74a83809103bcadd93ae98288f" + nextBlockHash: "151615691b209de41dda4798a07e62db8429488554077552ccb1c4f8c7e9f57a" + height: 2648059 + confirmations: 47 + size: 951 + time: 1553096617 + version: "6422787" + merkleRoot: "6783f6083788c4f69b8af23bd2e4a194cf36ac34d590dfd97e510fe7aebc72c8" + nonce: "0" + bits: "1a063f3b" + difficulty: "2685605.260733312" + txCount: 2 + txs: + - txid: "2b9fc57aaa8d01975631a703b0fc3f11d70671953fc769533b8078a04d029bf9" + vin: + - n: 0 + isAddress: false + value: "0" + vout: + - value: "1000100000000" + n: 0 + addresses: ["D6ravJL6Fgxtgp8k2XZZt1QfUmwwGuLwQJ"] + isAddress: true + blockHash: "760f8ed32894ccce9c1ea11c8a019cadaa82bcb434b25c30102dd7e43f326217" + blockHeight: 2648059 + confirmations: 47 + blockTime: 1553096617 + value: "1000100000000" + valueIn: "0" + fees: "0" + + EstimateFee: + summary: Fee estimate + value: + result: "0.00002460" + + FeeStats: + summary: Fee statistics + value: + txCount: 1820 + totalFeesSat: "182000000" + averageFeePerKb: 23.41 + decilesFeePerKb: [3.1, 5.4, 8.8, 11.2, 15.7, 20.3, 26.8, 35.1, 48.4, 91.6] + + TickersList: + summary: Available fiat currencies + value: + ts: 1574346615 + available_currencies: [eur, usd] + + FiatTickerAll: + summary: All available rates + value: + ts: 1574346615 + rates: + eur: 7134.1 + usd: 7914.5 + + FiatTickerUsd: + summary: Single currency rate + value: + ts: 1574346615 + rates: + usd: 7914.5 + + FiatTickerUnavailable: + summary: Unavailable rate marker + value: + ts: 7980386400 + rates: + usd: -1 + + MultiTickers: + summary: Rates for multiple timestamps + value: + - ts: 1574346615 + rates: + usd: 7914.5 + - ts: 1574433015 + rates: + usd: 7344.2 + + BalanceHistoryAllRates: + summary: Balance history with all rates + value: + - time: 1578391200 + txs: 5 + received: "5000000" + sent: "0" + sentToSelf: "100000" + rates: + usd: 7855.9 + eur: 6838.13 + - time: 1578488400 + txs: 1 + received: "0" + sent: "5000000" + sentToSelf: "0" + rates: + usd: 8283.11 + eur: 7464.45 + + BalanceHistoryUsd: + summary: Balance history with USD only + value: + - 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 + + BalanceHistoryGrouped: + summary: Grouped balance history + value: + - time: 1578355200 + txs: 6 + received: "5000000" + sent: "5000000" + sentToSelf: "0" + rates: + usd: 7734.45 + + WebSocketGetInfoRequest: + summary: Get current Blockbook info + value: + id: "1" + method: getInfo + params: {} + + WebSocketSubscribeAddressesRequest: + summary: Subscribe to address activity + value: + id: "1" + method: subscribeAddresses + params: + addresses: + - mnYYiDCb2JZXnqEeXta1nkt5oCVe2RVhJj + - tb1qp0we5epypgj4acd2c4au58045ruud2pd6heuee + newBlockTxs: true + + WebSocketGetContractInfoRequest: + summary: Get contract metadata with ERC4626 enrichment + value: + id: "1" + method: getContractInfo + params: + contract: "0x0000000000000000000000000000000000000001" + currency: usd + protocols: [erc4626] + + WebSocketGetBlockRequest: + summary: Get a block with paged transactions + value: + id: "1" + method: getBlock + params: + id: "760f8ed32894ccce9c1ea11c8a019cadaa82bcb434b25c30102dd7e43f326217" + page: 1 + pageSize: 1000 + + schemas: + LegacyObject: + type: object + description: Legacy Bitcore Insight-compatible payload. Use API V2 for stable typed schemas. + additionalProperties: true + + ErrorResponse: + type: object + required: [error] + properties: + error: + type: string + description: Human-readable error message. + + AmountString: + type: string + pattern: "^-?[0-9]+$" + description: Integer amount in the lowest chain denomination, encoded as a string. + examples: ["100000000", "17177839694340"] + + TokenStandard: + type: string + description: Token standard name. Empty string means no token standard is known. + enum: ["", XPUBAddress, ERC20, ERC721, ERC1155, BEP20, BEP721, BEP1155, TRC20, TRC721, TRC1155] + + BlockHashResponse: + type: object + required: [blockHash] + properties: + blockHash: + type: string + + ResultStringResponse: + type: object + required: [result] + properties: + result: + type: string + description: Result string, usually a decimal amount in chain base currency. + + SendTransactionResponse: + type: object + required: [result] + properties: + result: + type: string + description: Broadcast transaction id/hash. + + AddressAlias: + type: object + properties: + Type: + type: string + Alias: + type: string + + AddressAliases: + type: object + additionalProperties: + $ref: "#/components/schemas/AddressAlias" + + MultiTokenValue: + type: object + properties: + id: + $ref: "#/components/schemas/AmountString" + value: + $ref: "#/components/schemas/AmountString" + + TokenTransfer: + type: object + required: [type, standard, from, to, contract, decimals] + properties: + type: + deprecated: true + $ref: "#/components/schemas/TokenStandard" + standard: + $ref: "#/components/schemas/TokenStandard" + from: + type: string + to: + type: string + contract: + type: string + name: + type: string + symbol: + type: string + decimals: + type: integer + value: + $ref: "#/components/schemas/AmountString" + multiTokenValues: + type: array + items: + $ref: "#/components/schemas/MultiTokenValue" + + Vin: + type: object + required: [n, isAddress] + properties: + txid: + type: string + vout: + type: integer + minimum: 0 + sequence: + type: integer + n: + type: integer + minimum: 0 + addresses: + type: array + items: + type: string + isAddress: + type: boolean + isOwn: + type: boolean + value: + $ref: "#/components/schemas/AmountString" + hex: + type: string + asm: + type: string + coinbase: + type: string + + Vout: + type: object + required: [n, addresses, isAddress] + properties: + value: + $ref: "#/components/schemas/AmountString" + n: + type: integer + minimum: 0 + spent: + type: boolean + spentTxId: + type: string + spentIndex: + type: integer + spentHeight: + type: integer + hex: + type: string + asm: + type: string + addresses: + oneOf: + - type: array + items: + type: string + - type: "null" + isAddress: + type: boolean + isOwn: + type: boolean + type: + type: string + + EthereumInternalTransfer: + type: object + required: [type, from, to] + properties: + type: + type: integer + from: + type: string + to: + type: string + value: + $ref: "#/components/schemas/AmountString" + + EthereumParsedInputParam: + type: object + required: [type] + properties: + type: + type: string + values: + type: array + items: + type: string + + EthereumParsedInputData: + type: object + required: [methodId, name] + properties: + methodId: + type: string + description: First 4 bytes of the input data. + name: + type: string + description: Parsed function name when recognized. + function: + type: string + description: Full function signature when recognized. + params: + type: array + items: + $ref: "#/components/schemas/EthereumParsedInputParam" + + EthereumSpecific: + type: object + required: [status, nonce] + properties: + type: + type: integer + createdContract: + type: string + status: + type: integer + description: 1 success, 0 failed, -1 pending. + error: + type: string + nonce: + type: integer + format: int64 + gasLimit: + type: integer + format: int64 + gasUsed: + type: integer + format: int64 + gasPrice: + $ref: "#/components/schemas/AmountString" + maxPriorityFeePerGas: + $ref: "#/components/schemas/AmountString" + maxFeePerGas: + $ref: "#/components/schemas/AmountString" + baseFeePerGas: + $ref: "#/components/schemas/AmountString" + l1Fee: + type: integer + format: int64 + l1FeeScalar: + type: string + l1GasPrice: + $ref: "#/components/schemas/AmountString" + l1GasUsed: + type: integer + format: int64 + data: + type: string + parsedData: + $ref: "#/components/schemas/EthereumParsedInputData" + internalTransfers: + type: array + items: + $ref: "#/components/schemas/EthereumInternalTransfer" + + TxChainExtraData: + type: object + required: [payloadType] + properties: + payloadType: + type: string + description: Discriminator for normalized chain-specific payloads, for example tron. + payload: {} + + AccountChainExtraData: + type: object + required: [payloadType] + properties: + payloadType: + type: string + payload: {} + + Tx: + type: object + required: [txid, vin, vout, blockHeight, confirmations, blockTime] + properties: + txid: + type: string + version: + type: integer + lockTime: + type: integer + vin: + type: array + items: + $ref: "#/components/schemas/Vin" + vout: + type: array + items: + $ref: "#/components/schemas/Vout" + blockHash: + type: string + blockHeight: + type: integer + description: -1 for unconfirmed transactions. + confirmations: + type: integer + minimum: 0 + confirmationETABlocks: + type: integer + confirmationETASeconds: + type: integer + format: int64 + blockTime: + type: integer + format: int64 + size: + type: integer + vsize: + type: integer + value: + $ref: "#/components/schemas/AmountString" + valueIn: + $ref: "#/components/schemas/AmountString" + fees: + $ref: "#/components/schemas/AmountString" + hex: + type: string + rbf: + type: boolean + coinSpecificData: + description: Raw blockchain-specific transaction data. + chainExtraData: + $ref: "#/components/schemas/TxChainExtraData" + tokenTransfers: + type: array + items: + $ref: "#/components/schemas/TokenTransfer" + ethereumSpecific: + $ref: "#/components/schemas/EthereumSpecific" + addressAliases: + $ref: "#/components/schemas/AddressAliases" + + FeeStats: + type: object + required: [txCount, averageFeePerKb, decilesFeePerKb] + properties: + txCount: + type: integer + totalFeesSat: + $ref: "#/components/schemas/AmountString" + averageFeePerKb: + type: number + decilesFeePerKb: + type: array + items: + type: number + + Erc4626TokenMetadata: + type: object + required: [contract, decimals] + properties: + contract: + type: string + name: + type: string + symbol: + type: string + decimals: + type: integer + + Erc4626Token: + type: object + properties: + asset: + $ref: "#/components/schemas/Erc4626TokenMetadata" + share: + $ref: "#/components/schemas/Erc4626TokenMetadata" + totalAssets: + $ref: "#/components/schemas/AmountString" + convertToAssets1Share: + $ref: "#/components/schemas/AmountString" + convertToShares1Asset: + $ref: "#/components/schemas/AmountString" + previewDeposit1Asset: + $ref: "#/components/schemas/AmountString" + previewRedeem1Share: + $ref: "#/components/schemas/AmountString" + error: + type: string + + ContractInfoProtocols: + type: object + properties: + erc4626: + $ref: "#/components/schemas/Erc4626Token" + + ContractInfoRates: + type: object + properties: + baseRate: + type: number + currency: + type: string + secondaryRate: + type: number + + ContractInfoResult: + type: object + required: [type, standard, contract, name, symbol, decimals, blockHeight] + properties: + type: + deprecated: true + $ref: "#/components/schemas/TokenStandard" + standard: + $ref: "#/components/schemas/TokenStandard" + contract: + type: string + name: + type: string + symbol: + type: string + decimals: + type: integer + createdInBlock: + type: integer + destructedInBlock: + type: integer + rates: + $ref: "#/components/schemas/ContractInfoRates" + protocols: + $ref: "#/components/schemas/ContractInfoProtocols" + blockHeight: + type: integer + + Token: + type: object + required: [type, standard, name, transfers, decimals] + properties: + type: + deprecated: true + $ref: "#/components/schemas/TokenStandard" + standard: + $ref: "#/components/schemas/TokenStandard" + name: + type: string + path: + type: string + contract: + type: string + transfers: + type: integer + symbol: + type: string + decimals: + type: integer + balance: + $ref: "#/components/schemas/AmountString" + baseValue: + type: number + secondaryValue: + type: number + ids: + type: array + items: + $ref: "#/components/schemas/AmountString" + multiTokenValues: + type: array + items: + $ref: "#/components/schemas/MultiTokenValue" + totalReceived: + $ref: "#/components/schemas/AmountString" + totalSent: + $ref: "#/components/schemas/AmountString" + protocols: + type: array + description: Indexed protocol identifiers such as erc4626. + items: + type: string + + StakingPool: + type: object + required: [contract, name] + properties: + contract: + type: string + name: + type: string + pendingBalance: + $ref: "#/components/schemas/AmountString" + pendingDepositedBalance: + $ref: "#/components/schemas/AmountString" + depositedBalance: + $ref: "#/components/schemas/AmountString" + withdrawTotalAmount: + $ref: "#/components/schemas/AmountString" + claimableAmount: + $ref: "#/components/schemas/AmountString" + restakedReward: + $ref: "#/components/schemas/AmountString" + autocompoundBalance: + $ref: "#/components/schemas/AmountString" + + Address: + type: object + required: [address, unconfirmedTxs, txs] + properties: + page: + type: integer + totalPages: + type: integer + itemsOnPage: + type: integer + address: + type: string + balance: + $ref: "#/components/schemas/AmountString" + totalReceived: + $ref: "#/components/schemas/AmountString" + totalSent: + $ref: "#/components/schemas/AmountString" + unconfirmedBalance: + $ref: "#/components/schemas/AmountString" + unconfirmedTxs: + type: integer + unconfirmedSending: + $ref: "#/components/schemas/AmountString" + unconfirmedReceiving: + $ref: "#/components/schemas/AmountString" + txs: + type: integer + addrTxCount: + type: integer + nonTokenTxs: + type: integer + internalTxs: + type: integer + transactions: + type: array + items: + $ref: "#/components/schemas/Tx" + txids: + type: array + items: + type: string + nonce: + type: string + confirmedNonce: + type: string + usedTokens: + type: integer + tokens: + type: array + items: + $ref: "#/components/schemas/Token" + secondaryValue: + type: number + tokensBaseValue: + type: number + tokensSecondaryValue: + type: number + totalBaseValue: + type: number + totalSecondaryValue: + type: number + contractInfo: + $ref: "#/components/schemas/ContractInfoResult" + erc20Contract: + deprecated: true + $ref: "#/components/schemas/ContractInfoResult" + addressAliases: + $ref: "#/components/schemas/AddressAliases" + stakingPools: + type: array + items: + $ref: "#/components/schemas/StakingPool" + chainExtraData: + $ref: "#/components/schemas/AccountChainExtraData" + + Utxo: + type: object + required: [txid, vout, confirmations] + properties: + txid: + type: string + vout: + type: integer + minimum: 0 + value: + $ref: "#/components/schemas/AmountString" + height: + type: integer + confirmations: + type: integer + minimum: 0 + address: + type: string + path: + type: string + lockTime: + type: integer + coinbase: + type: boolean + + BalanceHistory: + type: object + required: [time, txs] + properties: + time: + type: integer + format: int64 + txs: + type: integer + received: + $ref: "#/components/schemas/AmountString" + sent: + $ref: "#/components/schemas/AmountString" + sentToSelf: + $ref: "#/components/schemas/AmountString" + rates: + type: object + additionalProperties: + type: number + txid: + type: string + + Block: + type: object + required: [hash, height, confirmations, txCount] + properties: + page: + type: integer + totalPages: + type: integer + itemsOnPage: + type: integer + hash: + type: string + previousBlockHash: + type: string + nextBlockHash: + type: string + height: + type: integer + confirmations: + type: integer + minimum: 0 + size: + type: integer + time: + type: integer + format: int64 + version: + oneOf: + - type: string + - type: integer + merkleRoot: + type: string + nonce: + type: string + bits: + type: string + difficulty: + type: string + tx: + type: array + description: Transaction ids when full transactions are not returned. + items: + type: string + txCount: + type: integer + txs: + type: array + description: Full transaction details for this page. + items: + $ref: "#/components/schemas/Tx" + addressAliases: + $ref: "#/components/schemas/AddressAliases" + + BlockRaw: + type: object + required: [hex] + properties: + hex: + type: string + + BlockFilters: + type: object + required: [P, M, zeroedKey, blockFilters] + properties: + P: + type: integer + M: + type: integer + format: int64 + zeroedKey: + type: boolean + blockFilters: + type: object + additionalProperties: + type: object + required: [blockHash, filter] + properties: + blockHash: + type: string + filter: + type: string + + BackendInfo: + type: object + properties: + error: + type: string + chain: + type: string + blocks: + type: integer + headers: + type: integer + bestBlockHash: + type: string + difficulty: + type: string + sizeOnDisk: + type: integer + format: int64 + version: + type: string + subversion: + type: string + protocolVersion: + type: string + timeOffset: + type: integer + warnings: + type: string + consensus_version: + type: string + consensus: + description: Chain-specific consensus data. + + InternalStateColumn: + type: object + properties: + name: + type: string + version: + type: integer + rows: + type: integer + keyBytes: + type: integer + format: int64 + valueBytes: + type: integer + format: int64 + updated: + type: string + + BlockbookInfo: + type: object + required: [coin, network, host, version, gitCommit, buildTime, syncMode, initialSync, inSync, bestHeight, decimals, about] + properties: + coin: + type: string + network: + type: string + host: + type: string + version: + type: string + gitCommit: + type: string + buildTime: + type: string + syncMode: + type: boolean + initialSync: + type: boolean + inSync: + type: boolean + bestHeight: + type: integer + lastBlockTime: + type: string + inSyncMempool: + type: boolean + lastMempoolTime: + type: string + mempoolSize: + type: integer + decimals: + type: integer + dbSize: + type: integer + format: int64 + hasFiatRates: + type: boolean + hasTokenFiatRates: + type: boolean + currentFiatRatesTime: + type: string + historicalFiatRatesTime: + type: string + historicalTokenFiatRatesTime: + type: string + supportedStakingPools: + type: array + items: + type: string + dbSizeFromColumns: + type: integer + format: int64 + dbColumns: + type: array + items: + $ref: "#/components/schemas/InternalStateColumn" + about: + type: string + + SystemInfo: + type: object + properties: + blockbook: + $ref: "#/components/schemas/BlockbookInfo" + backend: + $ref: "#/components/schemas/BackendInfo" + + FiatTicker: + type: object + required: [rates] + properties: + ts: + type: integer + format: int64 + rates: + type: object + additionalProperties: + type: number + error: + type: string + + AvailableVsCurrencies: + type: object + required: [available_currencies] + properties: + ts: + type: integer + format: int64 + available_currencies: + type: array + items: + type: string + error: + type: string + + WsRequest: + type: object + required: [id, method] + properties: + id: + type: string + description: Client-chosen request id echoed by the response. + method: + type: string + enum: + - getAccountInfo + - getContractInfo + - getInfo + - getBlockHash + - getBlock + - getAccountUtxo + - getBalanceHistory + - getTransaction + - getTransactionSpecific + - estimateFee + - longTermFeeRate + - sendTransaction + - getMempoolFilters + - getBlockFilter + - getBlockFiltersBatch + - rpcCall + - subscribeNewBlock + - unsubscribeNewBlock + - subscribeNewTransaction + - unsubscribeNewTransaction + - subscribeAddresses + - unsubscribeAddresses + - subscribeFiatRates + - unsubscribeFiatRates + - ping + - getCurrentFiatRates + - getFiatRatesForTimestamps + - getFiatRatesTickersList + params: + description: Method-specific request parameters. + oneOf: + - $ref: "#/components/schemas/WsAccountInfoReq" + - $ref: "#/components/schemas/WsContractInfoReq" + - $ref: "#/components/schemas/WsBlockHashReq" + - $ref: "#/components/schemas/WsBlockReq" + - $ref: "#/components/schemas/WsAccountUtxoReq" + - $ref: "#/components/schemas/WsBalanceHistoryReq" + - $ref: "#/components/schemas/WsTransactionReq" + - $ref: "#/components/schemas/WsTransactionSpecificReq" + - $ref: "#/components/schemas/WsEstimateFeeReq" + - $ref: "#/components/schemas/WsSendTransactionReq" + - $ref: "#/components/schemas/WsMempoolFiltersReq" + - $ref: "#/components/schemas/WsBlockFilterReq" + - $ref: "#/components/schemas/WsBlockFiltersBatchReq" + - $ref: "#/components/schemas/WsRpcCallReq" + - $ref: "#/components/schemas/WsSubscribeAddressesReq" + - $ref: "#/components/schemas/WsSubscribeFiatRatesReq" + - $ref: "#/components/schemas/WsCurrentFiatRatesReq" + - $ref: "#/components/schemas/WsFiatRatesForTimestampsReq" + - $ref: "#/components/schemas/WsFiatRatesTickersListReq" + - type: object + description: Empty parameter object for methods such as getInfo, ping, and unsubscribe methods. + + WsResponse: + type: object + required: [id, data] + properties: + id: + type: string + data: + description: Method-specific result, or WsErrorData on failure. + oneOf: + - $ref: "#/components/schemas/WsInfoRes" + - $ref: "#/components/schemas/WsBlockHashRes" + - $ref: "#/components/schemas/Block" + - $ref: "#/components/schemas/Address" + - type: array + items: + $ref: "#/components/schemas/Utxo" + - $ref: "#/components/schemas/Tx" + - $ref: "#/components/schemas/WsEstimateFeeRes" + - $ref: "#/components/schemas/ResultStringResponse" + - $ref: "#/components/schemas/FiatTicker" + - $ref: "#/components/schemas/FiatTickers" + - $ref: "#/components/schemas/AvailableVsCurrencies" + - $ref: "#/components/schemas/WsRpcCallRes" + - $ref: "#/components/schemas/MempoolTxidFilterEntries" + - $ref: "#/components/schemas/WsErrorData" + - type: object + + WsErrorData: + type: object + required: [error] + properties: + error: + type: object + required: [message] + properties: + message: + type: string + + WsAccountInfoReq: + type: object + required: [descriptor] + properties: + descriptor: + type: string + details: + type: string + enum: [basic, tokens, tokenBalances, txids, txslight, txs] + tokens: + type: string + enum: [derived, used, nonzero] + protocols: + type: array + items: + type: string + pageSize: + type: integer + page: + type: integer + from: + type: integer + to: + type: integer + contractFilter: + type: string + secondaryCurrency: + type: string + gap: + type: integer + confirmedNonce: + type: boolean + + WsContractInfoReq: + type: object + required: [contract] + properties: + contract: + type: string + currency: + type: string + protocols: + type: array + items: + type: string + + WsBackendInfo: + type: object + properties: + version: + type: string + subversion: + type: string + consensus_version: + type: string + consensus: + description: Chain-specific consensus data. + + WsInfoRes: + type: object + required: [name, shortcut, network, decimals, version, bestHeight, bestHash, block0Hash, testnet, backend] + properties: + name: + type: string + shortcut: + type: string + network: + type: string + decimals: + type: integer + version: + type: string + bestHeight: + type: integer + bestHash: + type: string + block0Hash: + type: string + testnet: + type: boolean + backend: + $ref: "#/components/schemas/WsBackendInfo" + + WsBlockHashReq: + type: object + required: [height] + properties: + height: + type: integer + minimum: 0 + + WsBlockHashRes: + type: object + required: [hash] + properties: + hash: + type: string + + WsBlockReq: + type: object + required: [id] + properties: + id: + type: string + pageSize: + type: integer + maximum: 10000 + page: + type: integer + + WsAccountUtxoReq: + type: object + required: [descriptor] + properties: + descriptor: + type: string + + WsBalanceHistoryReq: + type: object + required: [descriptor] + properties: + descriptor: + type: string + from: + type: integer + format: int64 + to: + type: integer + format: int64 + currencies: + type: array + items: + type: string + gap: + type: integer + groupBy: + type: integer + + WsTransactionReq: + type: object + required: [txid] + properties: + txid: + type: string + + WsTransactionSpecificReq: + type: object + required: [txid] + properties: + txid: + type: string + + WsEstimateFeeReq: + type: object + properties: + blocks: + type: array + items: + type: integer + specific: + type: object + additionalProperties: true + properties: + conservative: + type: boolean + txsize: + type: integer + from: + type: string + to: + type: string + data: + type: string + value: + type: string + + Eip1559Fee: + type: object + properties: + maxFeePerGas: + $ref: "#/components/schemas/AmountString" + maxPriorityFeePerGas: + $ref: "#/components/schemas/AmountString" + minWaitTimeEstimate: + type: number + maxWaitTimeEstimate: + type: number + + Eip1559Fees: + type: object + properties: + baseFeePerGas: + $ref: "#/components/schemas/AmountString" + low: + $ref: "#/components/schemas/Eip1559Fee" + medium: + $ref: "#/components/schemas/Eip1559Fee" + high: + $ref: "#/components/schemas/Eip1559Fee" + instant: + $ref: "#/components/schemas/Eip1559Fee" + networkCongestion: + type: number + latestPriorityFeeRange: + type: array + items: + $ref: "#/components/schemas/AmountString" + historicalPriorityFeeRange: + type: array + items: + $ref: "#/components/schemas/AmountString" + historicalBaseFeeRange: + type: array + items: + $ref: "#/components/schemas/AmountString" + priorityFeeTrend: + type: string + enum: [up, down] + baseFeeTrend: + type: string + enum: [up, down] + + WsEstimateFeeRes: + type: object + properties: + feePerTx: + $ref: "#/components/schemas/AmountString" + feePerUnit: + $ref: "#/components/schemas/AmountString" + feeLimit: + $ref: "#/components/schemas/AmountString" + eip1559: + $ref: "#/components/schemas/Eip1559Fees" + + WsSendTransactionReq: + type: object + properties: + hex: + type: string + disableAlternativeRpc: + type: boolean + default: false + + WsMempoolFiltersReq: + type: object + required: [scriptType, fromTimestamp] + properties: + scriptType: + type: string + fromTimestamp: + type: integer + M: + type: integer + format: int64 + + WsBlockFilterReq: + type: object + required: [scriptType, blockHash] + properties: + scriptType: + type: string + blockHash: + type: string + M: + type: integer + format: int64 + + WsBlockFiltersBatchReq: + type: object + required: [scriptType, bestKnownBlockHash] + properties: + scriptType: + type: string + bestKnownBlockHash: + type: string + pageSize: + type: integer + M: + type: integer + format: int64 + + WsRpcCallReq: + type: object + required: [to, data] + properties: + from: + type: string + to: + type: string + data: + type: string + + WsRpcCallRes: + type: object + required: [data] + properties: + data: + type: string + + WsSubscribeAddressesReq: + type: object + required: [addresses] + properties: + addresses: + type: array + items: + type: string + newBlockTxs: + type: boolean + + WsSubscribeFiatRatesReq: + type: object + properties: + currency: + type: string + tokens: + type: array + items: + type: string + + WsCurrentFiatRatesReq: + type: object + properties: + currencies: + type: array + items: + type: string + token: + type: string + + WsFiatRatesForTimestampsReq: + type: object + required: [timestamps] + properties: + timestamps: + type: array + items: + type: integer + format: int64 + currencies: + type: array + items: + type: string + token: + type: string + + WsFiatRatesTickersListReq: + type: object + properties: + timestamp: + type: integer + format: int64 + token: + type: string + + FiatTickers: + type: object + required: [tickers] + properties: + tickers: + type: array + items: + $ref: "#/components/schemas/FiatTicker" + + MempoolTxidFilterEntries: + type: object + properties: + entries: + type: object + additionalProperties: + type: string + usedZeroedKey: + type: boolean diff --git a/server/client_ip.go b/server/client_ip.go new file mode 100644 index 0000000000..fbc8271f73 --- /dev/null +++ b/server/client_ip.go @@ -0,0 +1,353 @@ +package server + +import ( + _ "embed" + "fmt" + "net" + "net/http" + "net/netip" + "os" + "strconv" + "strings" +) + +// embeddedCloudflareIPs holds Cloudflare's published edge ranges (one CIDR per +// line, #-comments), compiled in so a deployment needs no runtime file. Operators +// can override via _CLOUDFLARE_IPS (inline CIDRs or @/path/to/file). +// +//go:embed cloudflare_ips.txt +var embeddedCloudflareIPs string + +type clientIPConfig struct { + trustedProxies []netip.Prefix + cloudflarePrefixes []netip.Prefix + // trustPseudoIPv6 honors CF-Connecting-IPv6. Enable ONLY when the Cloudflare + // zone runs "Pseudo IPv4: Overwrite Headers" -- the only mode where Cloudflare + // sanitizes that header; otherwise it is forwarded verbatim and thus spoofable. + trustPseudoIPv6 bool + trustedEnvName string + cloudflareEnvName string + pseudoIPv6EnvName string +} + +func readClientIPConfig(network string) (clientIPConfig, error) { + prefix := strings.ToUpper(network) + cfg := clientIPConfig{} + + envName, value := lookupEnvWithFallback(prefix+"_TRUSTED_PROXIES", prefix+"_WS_TRUSTED_PROXIES") + cfg.trustedEnvName = envName + trusted, err := parseTrustedProxies(envName, value) + if err != nil { + return cfg, err + } + cfg.trustedProxies = trusted + + envName, value = lookupEnvWithFallback(prefix+"_CLOUDFLARE_IPS", prefix+"_WS_CLOUDFLARE_IPS") + cfg.cloudflareEnvName = envName + cloudflare, err := parseCloudflareProxies(envName, value) + if err != nil { + return cfg, err + } + cfg.cloudflarePrefixes = cloudflare + + cfg.pseudoIPv6EnvName = prefix + "_CLOUDFLARE_PSEUDO_IPV4" + trustPseudo, err := parseBoolEnv(cfg.pseudoIPv6EnvName, os.Getenv(cfg.pseudoIPv6EnvName)) + if err != nil { + return cfg, err + } + cfg.trustPseudoIPv6 = trustPseudo + return cfg, nil +} + +// parseBoolEnv parses an optional boolean env value, defaulting to false when +// unset/empty and failing fast on an unparseable value so a typo cannot be +// silently ignored. +func parseBoolEnv(envName, value string) (bool, error) { + v := strings.TrimSpace(value) + if v == "" { + return false, nil + } + b, err := strconv.ParseBool(v) + if err != nil { + return false, fmt.Errorf("%s: invalid value %q (want a boolean, e.g. \"true\" or \"false\")", envName, value) + } + return b, nil +} + +func lookupEnvWithFallback(primary, fallback string) (envName, value string) { + if v, ok := os.LookupEnv(primary); ok { + return primary, v + } + if v, ok := os.LookupEnv(fallback); ok { + return fallback, v + } + return primary, "" +} + +// 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) { + const minIPv4Bits = 8 + const minIPv6Bits = 16 + prefixes, err := parseCIDRList(envName, strings.Split(value, ",")) + if err != nil { + return nil, err + } + for _, p := range prefixes { + 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, p, bits, minIPv4Bits) + } + if p.Addr().Is6() && bits < minIPv6Bits { + return nil, fmt.Errorf("%s: refusing CIDR %q: prefix /%d is too broad (minimum /%d for IPv6)", envName, p, bits, minIPv6Bits) + } + } + return prefixes, nil +} + +// parseCloudflareProxies parses _CLOUDFLARE_IPS (legacy _WS_CLOUDFLARE_IPS): +// "" or "builtin" -> embedded edge ranges; "off"/"none"/"0" -> disabled (CF headers +// trusted from any peer); "@/path/to/file" or a comma-separated CIDR list -> custom. +// A non-empty result enables peer verification in resolveClientIP. Only the explicit +// "off" spellings return nil; a value that parses to no CIDRs is rejected so a typo +// cannot silently disable verification. +func parseCloudflareProxies(envName, value string) ([]netip.Prefix, error) { + trimmed := strings.TrimSpace(value) + switch strings.ToLower(trimmed) { + case "", "builtin", "default": + return parseCIDRSource(envName, "embedded cloudflare_ips.txt", embeddedCloudflareIPs) + case "off", "none", "false", "0", "disabled": + return nil, nil + } + if path, ok := strings.CutPrefix(trimmed, "@"); ok { + content, err := os.ReadFile(strings.TrimSpace(path)) + if err != nil { + return nil, fmt.Errorf("%s: cannot read CIDR file: %w", envName, err) + } + return parseCIDRSource(envName, fmt.Sprintf("file %q", path), string(content)) + } + return parseCIDRSource(envName, fmt.Sprintf("%q", value), trimmed) +} + +// parseCIDRSource parses CIDR-list content (inline env value or file contents) +// and rejects an empty result so a typo cannot silently disable verification; +// source names the origin of the content for the error message. +func parseCIDRSource(envName, source, content string) ([]netip.Prefix, error) { + prefixes, err := parseCIDRList(envName, splitCIDRList(content)) + if err != nil { + return nil, err + } + if len(prefixes) == 0 { + return nil, fmt.Errorf("%s: no CIDRs in %s; use \"builtin\", \"off\", \"@/path/to/file\", or a comma-separated CIDR list", envName, source) + } + return prefixes, nil +} + +// splitCIDRList splits CIDR-list content into raw items: commas and newlines +// both separate entries, and everything from # to the end of a line is a +// comment. Blank items are skipped later by parseCIDRList. +func splitCIDRList(content string) []string { + var raws []string + for _, line := range strings.Split(content, "\n") { + if i := strings.IndexByte(line, '#'); i >= 0 { + line = line[:i] + } + raws = append(raws, strings.Split(line, ",")...) + } + return raws +} + +// parseCIDRList parses CIDRs into masked prefixes, skipping blanks and rejecting +// IPv4-mapped notation. It applies no minimum-width check (Cloudflare's +// published ranges are intentionally wide and only ever matched against the TCP +// peer); parseTrustedProxies layers that check on top for trusted proxies. +func parseCIDRList(envName string, raws []string) ([]netip.Prefix, error) { + var prefixes []netip.Prefix + for _, raw := range raws { + 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) + } + prefixes = append(prefixes, p.Masked()) + } + return prefixes, nil +} + +// resolveClientIP returns the per-IP rate-limit address plus two flags: blockSafe +// (spoof-resistant enough for an IP blocklist) and fromHeader (came from a forwarding +// header, not the bare TCP peer). trustedProxies governs X-Real-Ip; cloudflareProxies +// governs the CF-Connecting-* headers (empty trusts them from any peer). Only +// CF-Connecting-IP is trusted unless trustPseudoIPv6 is set (see the field doc); when +// no header is trusted it falls back to the bare TCP peer. Per-branch trust decisions +// are explained inline below. +func resolveClientIP(r *http.Request, trustedProxies, cloudflareProxies []netip.Prefix, trustPseudoIPv6 bool) (ip string, blockSafe, fromHeader bool) { + host := r.RemoteAddr + if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + host = h + } + remote, remoteOK := parseAddr(host) + + // Default Cloudflare mode (no configured trusted proxies). Trust the + // CF-Connecting-* headers either from any peer (verification disabled) or + // only when the TCP peer is a published Cloudflare edge range or a + // loopback/private proxy fronting Cloudflare (verification enabled). For a + // direct public non-Cloudflare peer the headers are attacker-controlled and + // are ignored so they cannot spoof a client IP past the limiter or blocklist. + if len(trustedProxies) == 0 { + cfTrusted := len(cloudflareProxies) == 0 || (remoteOK && isTrustedProxy(remote, cloudflareProxies)) + if cfTrusted { + cfBlockSafe := len(cloudflareProxies) > 0 + // Pseudo-IPv4 Overwrite mode (opt-in): the real client is the + // Cloudflare-set CF-Connecting-IPv6; CF-Connecting-IP is a synthetic + // pseudo-IPv4. Prefer the IPv6 header, falling back to CF-Connecting-IP. + if trustPseudoIPv6 { + if ip, ok := parseIP(r.Header.Get("CF-Connecting-IPv6")); ok { + return ip, cfBlockSafe, true + } + } + // Default: CF-Connecting-IP is the only CF-* request header Cloudflare + // always overwrites with the verified client IP. CF-Connecting-IPv6 is + // not consulted here because Cloudflare forwards a client-supplied value + // verbatim outside Pseudo-IPv4 mode, making it spoofable. + if ip, ok := parseIP(r.Header.Get("CF-Connecting-IP")); ok { + return ip, cfBlockSafe, true + } + } + } + + // 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, true, true + } + } + + hadCFHeader := r.Header.Get("CF-Connecting-IP") != "" || r.Header.Get("CF-Connecting-IPv6") != "" + if remoteOK { + return remote.String(), !hadCFHeader, false + } + return strings.TrimSpace(r.RemoteAddr), !hadCFHeader, false +} + +// rateLimitKey returns the key used for per-IP limiting. IPv6 is aggregated to its +// /64 (a client routinely owns a whole /64, so keying the full /128 would let it +// evade limits by rotating the low 64 bits); IPv4 is keyed verbatim (IPv4-mapped +// IPv6 is unmapped first). Unparseable input is keyed verbatim. +func rateLimitKey(ip string) string { + addr, err := netip.ParseAddr(strings.TrimSpace(ip)) + if err != nil { + return ip + } + addr = addr.Unmap().WithZone("") + if addr.Is6() { + if p, err := addr.Prefix(64); err == nil { + return p.String() + } + } + return addr.String() +} + +// blockKey returns the key used for the temporary IP blocklists (WS and REST). +// Unlike rateLimitKey it keeps IPv6 at the full /128: a long-lived block must not +// take out an entire shared /64 (mobile carriers, CGNAT-style IPv6, and VPN exits +// routinely place many unrelated subscribers in one /64). The rate limiter still +// aggregates to /64, so a /64-rotating abuser gains no throughput by dodging the +// per-/128 block. IPv4 is keyed verbatim (blockKey == rateLimitKey); IPv4-mapped +// IPv6 is unmapped first, and anything unparseable is keyed verbatim. +func blockKey(ip string) string { + addr, err := netip.ParseAddr(strings.TrimSpace(ip)) + if err != nil { + return ip + } + return addr.Unmap().WithZone("").String() +} + +// isBlockableKey reports whether ip is safe to add to an IP blocklist. It +// refuses loopback/private/link-local addresses and any configured trusted-proxy +// or Cloudflare edge range, so a misconfiguration that collapses many clients +// onto a shared proxy/edge address (or the proxy itself) can never get that +// shared address -- and therefore every client behind it -- blocked. +func isBlockableKey(ip string, trustedProxies, cloudflareProxies []netip.Prefix) bool { + addr, ok := parseAddr(ip) + if !ok { + return false + } + if addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast() || addr.IsUnspecified() || addr.IsMulticast() { + return false + } + for _, p := range trustedProxies { + if p.Contains(addr) { + return false + } + } + for _, p := range cloudflareProxies { + if p.Contains(addr) { + return false + } + } + return true +} + +// isLocalOrTrustedProxyIP reports whether ip is a loopback/private/link-local +// address or falls inside a configured trusted-proxy range -- i.e. it names the +// operator's own infrastructure rather than a client. Used together with +// resolveClientIP's fromHeader to recognize degenerate attribution (a proxy +// that forwards no client IP, or the operator's own local tooling). +func isLocalOrTrustedProxyIP(ip string, trustedProxies []netip.Prefix) bool { + addr, ok := parseAddr(ip) + return ok && isTrustedProxy(addr, trustedProxies) +} + +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 + } + // Unmap IPv4-mapped IPv6 (::ffff:a.b.c.d -> a.b.c.d) so both notations share a + // key and IPv4 prefixes match, and strip the IPv6 zone so keys are zone-free and + // Prefix.Contains matches unzoned prefixes against link-local peers. + return addr.Unmap().WithZone(""), true +} + +// isTrustedProxy reports whether a forwarding header (X-Real-Ip, or CF-Connecting-* +// in the default Cloudflare branch) may be trusted from this TCP peer. Loopback and +// RFC1918/private peers are trusted implicitly (reverse proxy on the same host/LAN). +// Link-local peers (fe80::/10) are deliberately NOT: they are spoofable by any node +// on the link. An operator fronting Blockbook with a link-local proxy can still trust +// it via _TRUSTED_PROXIES (matched as an extra). +func isTrustedProxy(addr netip.Addr, extras []netip.Prefix) bool { + if addr.IsLoopback() || addr.IsPrivate() { + return true + } + for _, p := range extras { + if p.Contains(addr) { + return true + } + } + return false +} diff --git a/server/cloudflare_ips.txt b/server/cloudflare_ips.txt new file mode 100644 index 0000000000..8689c2ca26 --- /dev/null +++ b/server/cloudflare_ips.txt @@ -0,0 +1,34 @@ +# Cloudflare's published edge ranges (https://www.cloudflare.com/ips/, fetched +# 2026-06). Compiled into the binary via go:embed and used as the default +# (_CLOUDFLARE_IPS unset or "builtin") set of peers from which the +# CF-Connecting-* headers are trusted. Update this file when Cloudflare's +# published ranges drift, or point _CLOUDFLARE_IPS=@/path/to/file at an +# up-to-date copy without rebuilding. +# +# Format: one CIDR per line; blank lines and #-comments are ignored. + +# IPv4 (https://www.cloudflare.com/ips-v4/) +173.245.48.0/20 +103.21.244.0/22 +103.22.200.0/22 +103.31.4.0/22 +141.101.64.0/18 +108.162.192.0/18 +190.93.240.0/20 +188.114.96.0/20 +197.234.240.0/22 +198.41.128.0/17 +162.158.0.0/15 +104.16.0.0/13 +104.24.0.0/14 +172.64.0.0/13 +131.0.72.0/22 + +# IPv6 (https://www.cloudflare.com/ips-v6/) +2400:cb00::/32 +2606:4700::/32 +2803:f800::/32 +2405:b500::/32 +2405:8100::/32 +2a06:98c0::/29 +2c0f:f248::/32 diff --git a/server/html_templates.go b/server/html_templates.go new file mode 100644 index 0000000000..bbe1f4e9dc --- /dev/null +++ b/server/html_templates.go @@ -0,0 +1,445 @@ +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;" +} + +func getSwaggerContentSecurityPolicy() string { + return "default-src 'none'; " + + "script-src 'self' https://cdn.jsdelivr.net; " + + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + + "img-src 'self' data:; " + + "connect-src 'self'; " + + "font-src 'self' data:; " + + "object-src 'none'; " + + "frame-ancestors 'none'; " + + "base-uri 'none'; " + + "form-action 'none';" +} + +func getOpenAPISpecContentSecurityPolicy() string { + return "default-src 'none'; " + + "object-src 'none'; " + + "frame-ancestors 'none'; " + + "base-uri 'none'; " + + "form-action 'none';" +} + +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..a56eefe029 --- /dev/null +++ b/server/html_templates_test.go @@ -0,0 +1,463 @@ +//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) + } +} + +func Test_jsStrEscapesQRCodeTextInJSContext(t *testing.T) { + tmpl := template.Must(template.New("qrcode").Funcs(template.FuncMap{ + "jsStr": jsStr, + }).Parse(``)) + + var rendered bytes.Buffer + if err := tmpl.Execute(&rendered, `wpkh(xpub)"});alert(1);//`); err != nil { + t.Fatalf("Execute() error = %v", err) + } + body := rendered.String() + + if strings.Contains(body, `text: "wpkh(xpub)"});alert(1);//"`) { + t.Fatalf("found unescaped QR code text breakout payload in output: %s", body) + } + if !strings.Contains(body, `text: "wpkh(xpub)\"});alert(1);//"`) { + t.Fatalf("escaped QR code text literal not found in output: %s", body) + } +} diff --git a/server/internal.go b/server/internal.go index f690293f94..30584f05a8 100644 --- a/server/internal.go +++ b/server/internal.go @@ -2,20 +2,33 @@ package server import ( "context" + "crypto/sha256" + "crypto/subtle" "encoding/json" "fmt" + "html/template" + "io" "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" "github.com/golang/glog" + "github.com/juju/errors" "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/syscoin/blockbook/api" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/common" - "github.com/syscoin/blockbook/db" + "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 @@ -25,11 +38,18 @@ type InternalServer struct { mempool bchain.Mempool is *common.InternalState api *api.Worker + // Admin HTTP Basic-auth credentials for the /admin endpoints, derived from + // BB_ADMIN_USER/BB_ADMIN_PASSWORD. adminAuthEnabled is false when either is unset, + // keeping the admin surface fail-closed (see requireAdminAuth). Only SHA-256 + // digests are retained, for constant-time comparison. + adminAuthEnabled bool + adminUserHash [32]byte + adminPassHash [32]byte } // 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 +61,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,14 +74,107 @@ 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() + + // The internal server binds all interfaces by default (configs/coins/*: + // internal_binding_template is ":"), so the /admin endpoints are gated by + // HTTP Basic auth. Basic auth (rather than a bearer token) lets the admin HTML + // pages and forms be used directly from a browser via its native login prompt. + // Credentials come from the process environment like the other runtime secrets + // (see docs/env.md, blockbook.env). + s.configureAdminAuth(os.Getenv("BB_ADMIN_USER"), os.Getenv("BB_ADMIN_PASSWORD")) + if s.adminAuthEnabled { + glog.Info("internal server: /admin authentication enabled (HTTP Basic auth)") + } else { + glog.Warning("internal server: BB_ADMIN_USER/BB_ADMIN_PASSWORD not both set; /admin endpoints are disabled (HTTP 503). Set them in blockbook.env to enable them.") + } 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) - + // Gate the whole /admin surface behind auth. The trailing-slash catch-all keeps + // unregistered /admin/* subpaths authenticated (so they cannot fall through to the + // public index handler); a bare "/admin/" is redirected to the canonical "/admin". + adminPath := path + "admin" + serveMux.HandleFunc(adminPath, s.requireAdminAuth(s.htmlTemplateHandler(s.adminIndex))) + serveMux.HandleFunc(adminPath+"/", s.requireAdminAuth(s.adminSubtreeHandler(adminPath))) + serveMux.HandleFunc(adminPath+"/ws-limit-exceeding-ips", s.requireAdminAuth(s.htmlTemplateHandler(s.wsLimitExceedingIPs))) + if s.chainParser.GetChainType() == bchain.ChainEthereumType { + serveMux.HandleFunc(adminPath+"/internal-data-errors", s.requireAdminAuth(s.htmlTemplateHandler(s.internalDataErrors))) + serveMux.HandleFunc(adminPath+"/contract-info", s.requireAdminAuth(s.htmlTemplateHandler(s.contractInfoPage))) + serveMux.HandleFunc(adminPath+"/contract-info/", s.requireAdminAuth(s.jsonHandler(s.apiContractInfo, 0))) + } return s, nil } +// configureAdminAuth derives the /admin Basic-auth credentials from the given raw +// values (the BB_ADMIN_USER/BB_ADMIN_PASSWORD environment variables). Surrounding +// whitespace is stripped so a stray space or newline in blockbook.env does not lock +// the operator out. If either value is empty the admin surface stays disabled +// (fail-closed). Only the SHA-256 digests are kept, for constant-time comparison. +func (s *InternalServer) configureAdminAuth(rawUser, rawPass string) { + user := strings.TrimSpace(rawUser) + pass := strings.TrimSpace(rawPass) + s.adminAuthEnabled = user != "" && pass != "" + s.adminUserHash = sha256.Sum256([]byte(user)) + s.adminPassHash = sha256.Sum256([]byte(pass)) +} + +// adminSubtreeHandler backs the /admin/ trailing-slash catch-all. A bare "/admin/" +// (the trailing-slash form of the index) is redirected to the canonical adminPath +// ("/admin"); any deeper unregistered /admin/* path is a 404. It is registered behind +// requireAdminAuth, so unknown subpaths stay gated rather than reaching the index. +func (s *InternalServer) adminSubtreeHandler(adminPath string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == adminPath+"/" { + http.Redirect(w, r, adminPath, http.StatusFound) + return + } + http.NotFound(w, r) + } +} + +// requireAdminAuth wraps an internal-server handler so it is reachable only with +// valid HTTP Basic credentials. The admin surface is fail-closed: when the +// credentials are not configured the endpoints return 503 rather than serving +// unauthenticated, because the internal server binds all interfaces by default. +func (s *InternalServer) requireAdminAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !s.adminAuthEnabled { + http.Error(w, "admin interface disabled", http.StatusServiceUnavailable) + return + } + if !s.validBasicAuth(r) { + // Prompt browsers for credentials; charset advertises UTF-8 passwords. + w.Header().Set("WWW-Authenticate", `Basic realm="blockbook-admin", charset="UTF-8"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next(w, r) + } +} + +// validBasicAuth reports whether the request carries the configured admin Basic +// credentials. Both fields are compared via SHA-256 digests so the comparison is +// constant-time and independent of the submitted lengths; both comparisons are +// evaluated before being combined so a username mismatch is not distinguishable by +// timing from a password mismatch. +func (s *InternalServer) validBasicAuth(r *http.Request) bool { + user, pass, ok := r.BasicAuth() + if !ok { + return false + } + gotUser := sha256.Sum256([]byte(user)) + gotPass := sha256.Sum256([]byte(pass)) + userOK := subtle.ConstantTimeCompare(gotUser[:], s.adminUserHash[:]) == 1 + passOK := subtle.ConstantTimeCompare(gotPass[:], s.adminPassHash[:]) == 1 + return userOK && passOK +} + // Run starts the server func (s *InternalServer) Run() error { if s.certFiles == "" { @@ -97,3 +213,189 @@ 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 +} + +// WsBlockedIPView is a single row of the websocket IP blocklist rendered on the +// admin page (times pre-formatted so the template needs no time helpers). +type WsBlockedIPView struct { + Key string + Breaches int + Rejected int + BlockedAt string + Until string + Remaining string +} + +// 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 + WsBlockedIPs []WsBlockedIPView +} + +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 { + // The page has two reset buttons; reset=blocked clears the temporary IP + // blocklist, anything else (including the legacy button with no field) + // clears the getAccountInfo limit-exceeding counters. + if r.FormValue("reset") == "blocked" { + s.is.ResetWsBlockedIPs() + } else { + s.is.ResetWsLimitExceedingIPs() + } + } + data := s.newTemplateData(r) + // snapshot under the InternalState mutex; ranging over the live map races + // with AddWsLimitExceedingIP + exceeding := s.is.WsLimitExceedingIPsSnapshot() + ips := make([]WsLimitExceedingIP, 0, len(exceeding)) + for k, v := range exceeding { + 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 + + now := time.Now() + for _, b := range s.is.WsBlockedIPsSnapshot(now) { + data.WsBlockedIPs = append(data.WsBlockedIPs, WsBlockedIPView{ + Key: b.Key, + Breaches: b.Breaches, + Rejected: b.Rejected, + BlockedAt: b.BlockedAt.UTC().Format(time.RFC3339), + Until: b.Until.UTC().Format(time.RFC3339), + Remaining: b.Until.Sub(now).Round(time.Second).String(), + }) + } + 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/internal_test.go b/server/internal_test.go new file mode 100644 index 0000000000..a0ab85fb5c --- /dev/null +++ b/server/internal_test.go @@ -0,0 +1,125 @@ +//go:build unittest + +package server + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// okHandler is a sentinel "next" handler; reaching it means auth passed. +func okHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} + +func newAdminServer(user, pass string) *InternalServer { + s := &InternalServer{} + s.configureAdminAuth(user, pass) + return s +} + +func TestRequireAdminAuth(t *testing.T) { + const user, pass = "admin", "s3cr3t-pass" + tests := []struct { + name string + cfgUser string + cfgPass string + setAuth bool + user string + pass string + wantStatus int + }{ + {"no creds configured -> fail-closed 503", "", "", true, user, pass, http.StatusServiceUnavailable}, + {"only user configured -> 503", user, "", true, user, pass, http.StatusServiceUnavailable}, + {"only pass configured -> 503", "", pass, true, user, pass, http.StatusServiceUnavailable}, + {"configured, no Authorization header -> 401", user, pass, false, "", "", http.StatusUnauthorized}, + {"configured, wrong user -> 401", user, pass, true, "nope", pass, http.StatusUnauthorized}, + {"configured, wrong pass -> 401", user, pass, true, user, "nope", http.StatusUnauthorized}, + {"configured, valid creds -> 200", user, pass, true, user, pass, http.StatusOK}, + // Regression for the asymmetric-trim lockout: surrounding whitespace in the + // configured values is stripped, so the operator logs in with the clean value. + {"configured creds whitespace-padded, clean login -> 200", " " + user + "\n", " " + pass + " ", true, user, pass, http.StatusOK}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newAdminServer(tt.cfgUser, tt.cfgPass) + h := s.requireAdminAuth(okHandler) + r := httptest.NewRequest(http.MethodGet, "/admin", nil) + if tt.setAuth { + r.SetBasicAuth(tt.user, tt.pass) + } + w := httptest.NewRecorder() + h(w, r) + if w.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", w.Code, tt.wantStatus) + } + // A 401 must advertise the scheme so browsers show their login prompt. + if tt.wantStatus == http.StatusUnauthorized { + if got := w.Header().Get("WWW-Authenticate"); got == "" { + t.Errorf("401 response missing WWW-Authenticate header") + } + } + }) + } +} + +// TestAdminSubtreeIsGated guards against the ServeMux fall-through where +// trailing-slash or unknown /admin paths would otherwise reach the unauthenticated +// index handler. It mirrors the route registration in NewInternalServer. +func TestAdminSubtreeIsGated(t *testing.T) { + s := newAdminServer("admin", "pass") + mux := http.NewServeMux() + // Unauthenticated index, as registered for "/". + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("INDEX")) + }) + mux.HandleFunc("/admin", s.requireAdminAuth(okHandler)) + mux.HandleFunc("/admin/", s.requireAdminAuth(s.adminSubtreeHandler("/admin"))) + + // Without credentials, every /admin path is gated (401) and never reaches the + // unauthenticated index handler. + for _, p := range []string{"/admin", "/admin/", "/admin/unknown", "/admin/contract-info"} { + t.Run("no-auth "+p, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, p, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Errorf("%s: status = %d, want 401 (gated by admin auth)", p, w.Code) + } + if w.Body.String() == "INDEX" { + t.Errorf("%s: leaked the unauthenticated index handler", p) + } + }) + } + + // With valid credentials, a bare /admin/ canonicalizes to /admin and an unknown + // subpath is a gated 404 -- never the index. + authed := []struct { + path string + wantCode int + wantLoc string + }{ + {"/admin/", http.StatusFound, "/admin"}, + {"/admin/unknown", http.StatusNotFound, ""}, + } + for _, tc := range authed { + t.Run("auth "+tc.path, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, tc.path, nil) + r.SetBasicAuth("admin", "pass") + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Code != tc.wantCode { + t.Errorf("%s: status = %d, want %d", tc.path, w.Code, tc.wantCode) + } + if tc.wantLoc != "" && w.Header().Get("Location") != tc.wantLoc { + t.Errorf("%s: Location = %q, want %q", tc.path, w.Header().Get("Location"), tc.wantLoc) + } + if w.Body.String() == "INDEX" { + t.Errorf("%s: leaked the unauthenticated index handler", tc.path) + } + }) + } +} diff --git a/server/public.go b/server/public.go index 13a874eca4..58a7c59ec3 100644 --- a/server/public.go +++ b/server/public.go @@ -1,37 +1,60 @@ package server import ( - "bytes" "context" + "encoding/base64" "encoding/json" - "errors" "fmt" + "html" "html/template" - "io/ioutil" + "io" "math/big" "net/http" "net/url" + "os" "path/filepath" "reflect" "regexp" "runtime" - "runtime/debug" + "sort" "strconv" "strings" "time" - "encoding/base64" "github.com/golang/glog" - "github.com/syscoin/blockbook/api" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/common" - "github.com/syscoin/blockbook/db" + "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" ) 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 apiDocsIndexFile = "./static/api-docs/index.html" +const openAPIFile = "./openapi.yaml" +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 @@ -39,76 +62,100 @@ 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 + serveMux *http.ServeMux + restLimiter *restUIRateLimiter + 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) { +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) { - 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 nil, err } - socketio, err := NewSocketIoServer(db, chain, mempool, txCache, metrics, is) + websocket, err := NewWebsocketServer(db, chain, mempool, txCache, metrics, is, fiatRates) if err != nil { return nil, err } - websocket, err := NewWebsocketServer(db, chain, mempool, txCache, metrics, is, enableSubNewTx) + addr, path := splitBinding(binding) + serveMux := http.NewServeMux() + restLimiter, err := newRestUIRateLimiter(is.GetNetwork(), metrics) if err != nil { return nil, err } - - addr, path := splitBinding(binding) - serveMux := http.NewServeMux() + handler := http.Handler(serveMux) + if restLimiter != nil { + // the base path must be derived exactly like the route registrations in + // ConnectFullPublicInterface (raw concatenation, not publicPath), so the + // limiter covers the same paths the mux actually serves for every + // binding shape. The limiter governs all dynamic routes under path (the + // explorer UI pages and the REST API) under one shared per-client budget + handler = restLimiter.wrapPublic(handler, path) + } https := &http.Server{ Addr: addr, - Handler: serveMux, + Handler: handler, } 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, + serveMux: serveMux, + restLimiter: restLimiter, + 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 - serveMux.Handle(path+"favicon.ico", http.FileServer(http.Dir("./static/"))) - serveMux.Handle(path+"static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/")))) + serveMux.Handle(publicPath(path, "favicon.ico"), prefixedStaticFileServer(publicPath(path, ""))) + staticPath := publicPath(path, "static") + "/" + serveMux.Handle(staticPath, prefixedStaticFileServer(staticPath)) + apiDocsPath := publicPath(path, "api-docs") + serveMux.HandleFunc(apiDocsPath, s.apiDocsHandler(path)) + serveMux.HandleFunc(apiDocsPath+"/", s.apiDocsHandler(path)) + serveMux.HandleFunc(publicPath(path, "openapi.yaml"), s.openAPISpecHandler) // default handler serveMux.HandleFunc(path, s.htmlTemplateHandler(s.explorerIndex)) // default API handler @@ -129,17 +176,18 @@ func (s *PublicServer) Run() error { // ConnectFullPublicInterface enables complete public functionality func (s *PublicServer) ConnectFullPublicInterface() { - serveMux := s.https.Handler.(*http.ServeMux) + serveMux := s.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/"))) + serveMux.Handle(publicPath(path, "test-websocket.html"), prefixedStaticFileServer(publicPath(path, ""))) if s.internalExplorer { // internal explorer handlers serveMux.HandleFunc(path+"tx/", s.htmlTemplateHandler(s.explorerTx)) serveMux.HandleFunc(path+"address/", s.htmlTemplateHandler(s.explorerAddress)) - serveMux.HandleFunc(path+"asset/", s.htmlTemplateHandler(s.explorerAsset)) - serveMux.HandleFunc(path+"assets/", s.htmlTemplateHandler(s.explorerAssets)) + if isSyscoinShortcut(s.is.CoinShortcut) { + serveMux.HandleFunc(path+"asset/", s.htmlTemplateHandler(s.explorerAsset)) // SYSCOIN + serveMux.HandleFunc(path+"assets/", s.htmlTemplateHandler(s.explorerAssets)) // SYSCOIN + } serveMux.HandleFunc(path+"xpub/", s.htmlTemplateHandler(s.explorerXpub)) serveMux.HandleFunc(path+"search/", s.htmlTemplateHandler(s.explorerSearch)) serveMux.HandleFunc(path+"blocks", s.htmlTemplateHandler(s.explorerBlocks)) @@ -147,6 +195,9 @@ func (s *PublicServer) ConnectFullPublicInterface() { serveMux.HandleFunc(path+"spending/", s.htmlTemplateHandler(s.explorerSpendingTx)) serveMux.HandleFunc(path+"sendtx", s.htmlTemplateHandler(s.explorerSendTx)) serveMux.HandleFunc(path+"mempool", s.htmlTemplateHandler(s.explorerMempool)) + if s.chainParser.GetChainType() == bchain.ChainEthereumType { + serveMux.HandleFunc(path+"nft/", s.htmlTemplateHandler(s.explorerNftDetail)) + } } else { // redirect to wallet requests for tx and address, possibly to external site serveMux.HandleFunc(path+"tx/", s.txRedirect) @@ -173,9 +224,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)) @@ -185,13 +239,16 @@ 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/asset/", s.jsonHandler(s.apiAsset, apiV2)) - serveMux.HandleFunc(path+"api/v2/getchaintips/", s.jsonHandler(s.apiGetChainTips, apiV2)) - serveMux.HandleFunc(path+"api/v2/getspvproof/", s.jsonHandler(s.apiGetSPVProof, apiV2)) - serveMux.HandleFunc(path+"api/v2/assets/", s.jsonHandler(s.apiAssets, apiV2)) + if isSyscoinShortcut(s.is.CoinShortcut) { + serveMux.HandleFunc(path+"api/v2/asset/", s.jsonHandler(s.apiAsset, apiV2)) // SYSCOIN + serveMux.HandleFunc(path+"api/v2/assets/", s.jsonHandler(s.apiAssets, apiV2)) // SYSCOIN + serveMux.HandleFunc(path+"api/v2/getspvproof/", s.jsonHandler(s.apiGetSPVProof, apiV2)) // SYSCOIN + } + 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)) @@ -202,11 +259,15 @@ func (s *PublicServer) ConnectFullPublicInterface() { serveMux.HandleFunc(path+"api/v2/balancehistory/", s.jsonHandler(s.apiBalanceHistory, apiDefault)) 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.apiTickersList, apiV2)) - // socket.io interface - serveMux.Handle(path+"socket.io/", s.socketio.GetHandler()) + serveMux.HandleFunc(path+"api/v2/tickers-list/", s.jsonHandler(s.apiAvailableVsCurrencies, apiV2)) // 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 @@ -215,46 +276,129 @@ 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 -func (s *PublicServer) OnNewFiatRatesTicker(ticker *db.CurrencyRatesTicker) { +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) } func (s *PublicServer) txRedirect(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, joinURL(s.explorerURL, r.URL.Path), 302) + http.Redirect(w, r, joinURL(s.explorerURL, r.URL.Path), http.StatusFound) s.metrics.ExplorerViews.With(common.Labels{"action": "tx-redirect"}).Inc() } func (s *PublicServer) addressRedirect(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, joinURL(s.explorerURL, r.URL.Path), 302) + http.Redirect(w, r, joinURL(s.explorerURL, r.URL.Path), http.StatusFound) s.metrics.ExplorerViews.With(common.Labels{"action": "address-redirect"}).Inc() } -func (s *PublicServer) assetRedirect(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, joinURL(s.explorerURL, r.URL.Path), 302) - s.metrics.ExplorerViews.With(common.Labels{"action": "asset-redirect"}).Inc() +func (s *PublicServer) apiDocsHandler(basePath string) http.HandlerFunc { + docsPath := publicPath(basePath, "api-docs") + specPath := docsPath + "/openapi.yaml" + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case docsPath: + if !allowGetOrHead(w, r) { + return + } + http.Redirect(w, r, docsPath+"/", http.StatusMovedPermanently) + case docsPath + "/": + s.serveAPIDocs(w, r) + case specPath: + s.openAPISpecHandler(w, r) + default: + setOpenAPISecurityHeaders(w, "text/plain; charset=utf-8", false) + http.NotFound(w, r) + } + } +} + +func (s *PublicServer) serveAPIDocs(w http.ResponseWriter, r *http.Request) { + if !allowGetOrHead(w, r) { + return + } + if s.metrics != nil { + s.metrics.ExplorerViews.With(common.Labels{"action": "api-docs"}).Inc() + } + serveStaticContent(w, r, apiDocsIndexFile, "text/html; charset=utf-8", true) +} + +func (s *PublicServer) openAPISpecHandler(w http.ResponseWriter, r *http.Request) { + if !allowGetOrHead(w, r) { + return + } + if s.metrics != nil { + s.metrics.ExplorerViews.With(common.Labels{"action": "openapi-spec"}).Inc() + } + serveStaticContent(w, r, openAPIFile, "application/yaml; charset=utf-8", false) +} + +func serveStaticContent(w http.ResponseWriter, r *http.Request, filename string, contentType string, allowSwaggerAssets bool) { + body, err := os.ReadFile(filename) + if err != nil { + glog.Errorf("serve %s: %v", filename, err) + setOpenAPISecurityHeaders(w, "text/plain; charset=utf-8", allowSwaggerAssets) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + setOpenAPISecurityHeaders(w, contentType, allowSwaggerAssets) + w.Header().Set("Cache-Control", "no-store") + if r.Method == http.MethodHead { + return + } + if _, err := w.Write(body); err != nil { + glog.Warning("write response ", err) + } +} + +func allowGetOrHead(w http.ResponseWriter, r *http.Request) bool { + if r.Method == http.MethodGet || r.Method == http.MethodHead { + return true + } + w.Header().Set("Allow", "GET, HEAD") + setOpenAPISecurityHeaders(w, "text/plain; charset=utf-8", false) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return false +} + +func setOpenAPISecurityHeaders(w http.ResponseWriter, contentType string, allowSwaggerAssets bool) { + w.Header().Set("Content-Type", contentType) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "camera=(), geolocation=(), microphone=(), payment=(), usb=()") + if allowSwaggerAssets { + w.Header().Set("Content-Security-Policy", getSwaggerContentSecurityPolicy()) + } else { + w.Header().Set("Content-Security-Policy", getOpenAPISpecContentSecurityPolicy()) + } +} + +func prefixedStaticFileServer(prefix string) http.Handler { + return http.StripPrefix(prefix, http.FileServer(http.Dir("./static/"))) } func splitBinding(binding string) (addr string, path string) { @@ -265,6 +409,13 @@ func splitBinding(binding string) (addr string, path string) { return binding, "/" } +func publicPath(basePath string, item string) string { + if basePath == "/" { + return "/" + item + } + return strings.TrimRight(basePath, "/") + "/" + item +} + func joinURL(base string, part string) string { if len(base) > 0 { if len(base) > 0 && base[len(base)-1] == '/' && len(part) > 0 && part[0] == '/' { @@ -285,64 +436,8 @@ 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() *TemplateData { - return &TemplateData{ +func (s *PublicServer) newTemplateData(r *http.Request) *TemplateData { + t := &TemplateData{ CoinName: s.is.Coin, CoinShortcut: s.is.CoinShortcut, CoinLabel: s.is.CoinLabel, @@ -350,138 +445,207 @@ func (s *PublicServer) newTemplateData() *TemplateData { InternalExplorer: s.internalExplorer && !s.is.InitialSync, TOSLink: api.Text.TOSLink, } -} - -func (s *PublicServer) newTemplateDataWithError(text string) *TemplateData { - td := s.newTemplateData() - td.Error = &api.APIError{Text: text} - 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)) - } else { - data = s.newTemplateDataWithError("Internal server error") - } - } - // 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) - } + 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.4" + } + if s.is.HasFiatRates { + // get the secondary coin and if it should be shown either from query parameters "secondary" and "use_secondary" + // or from the cookie "secondary_coin" in the format secondary=use_secondary, for example EUR=true + // the query parameters take precedence over the cookie + var cookieSecondary string + var cookieUseSecondary bool + cookie, _ := r.Cookie(secondaryCoinCookieName) + if cookie != nil { + a := strings.Split(cookie.Value, "=") + if len(a) == 2 { + cookieSecondary = a[0] + cookieUseSecondary, _ = strconv.ParseBool(a[1]) } - 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() - data.Error = apiErr - if apiErr.Public { - t = errorTpl - } + } + secondary := strings.ToLower(r.URL.Query().Get("secondary")) + if secondary == "" { + if cookieSecondary != "" { + secondary = strings.ToLower(cookieSecondary) } 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)) - } else { - data = s.newTemplateDataWithError("Internal server error") - } + secondary = "usd" + } + } + ticker := s.fiatRates.GetCurrentTicker(secondary, "") + if ticker == nil && secondary != "usd" { + secondary = "usd" + ticker = s.fiatRates.GetCurrentTicker(secondary, "") + } + if ticker != nil { + t.SecondaryCoin = strings.ToUpper(secondary) + t.CurrentSecondaryCoinRate = float64(ticker.Rates[secondary]) + t.CurrentTicker = ticker + t.SecondaryCurrencies = make([]string, 0, len(ticker.Rates)) + for k := range ticker.Rates { + t.SecondaryCurrencies = append(t.SecondaryCurrencies, strings.ToUpper(k)) + } + sort.Strings(t.SecondaryCurrencies) // sort to get deterministic results + t.UseSecondaryCoin, _ = strconv.ParseBool(r.URL.Query().Get("use_secondary")) + if !t.UseSecondaryCoin { + t.UseSecondaryCoin = cookieUseSecondary } } } + return t } -type tpl int +func (s *PublicServer) newTemplateDataWithError(error *api.APIError, r *http.Request) *TemplateData { + td := s.newTemplateData(r) + td.Error = error + return td +} const ( - noTpl = tpl(iota) - errorTpl - errorInternalTpl - indexTpl + indexTpl = iota + errorInternalTpl + 1 txTpl addressTpl - assetTpl - assetsTpl + assetTpl // SYSCOIN + assetsTpl // SYSCOIN xpubTpl blocksTpl blockTpl sendTransactionTpl mempoolTpl + nftDetailTpl - tplCount + publicTplCount ) + // TemplateData is used to transfer data to the templates type TemplateData struct { - CoinName string - CoinShortcut string - CoinLabel string - InternalExplorer bool - ChainType bchain.ChainType - Address *api.Address - AddrStr string - Asset *api.Asset - Assets *api.Assets - Tx *api.Tx - Error *api.APIError - Blocks *api.Blocks - Block *api.Block - Info *api.SystemInfo - MempoolTxids *api.MempoolTxids - Page int - PrevPage int - NextPage int - PagingRange []int - PageParams template.URL - TOSLink string - SendTxHex string - Status string - NonZeroBalanceTokens bool + CoinName string + CoinShortcut string + CoinLabel string + InternalExplorer bool + ChainType bchain.ChainType + FungibleTokenName bchain.TokenStandardName + NonFungibleTokenName bchain.TokenStandardName + MultiTokenName bchain.TokenStandardName + Address *api.Address + AddrStr string + Asset *api.Asset // SYSCOIN + Assets *api.Assets // SYSCOIN + Tx *api.Tx + Error *api.APIError + Blocks *api.Blocks + Block *api.Block + Info *api.SystemInfo + MempoolTxids *api.MempoolTxids + Page int + PrevPage int + NextPage int + PagingRange []int + PageParams template.URL + Minified string + TOSLink string + SendTxHex string + SendTxMaxFeeRate string // SYSCOIN + SendTxMaxBurnAmount string // SYSCOIN + Status string + NonZeroBalanceTokens bool + TokenId string + URI string + ContractInfo *bchain.ContractInfo + SecondaryCoin string + UseSecondaryCoin bool + CurrentSecondaryCoinRate float64 + CurrentTicker *common.CurrencyRatesTicker + SecondaryCurrencies []string + TxDate string + TxSecondaryCoinRate float64 + 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 +} + +// SYSCOIN +func isSyscoinShortcut(coinShortcut string) bool { + shortcut := strings.ToUpper(strings.TrimSpace(coinShortcut)) + return shortcut == "SYS" || shortcut == "TSYS" +} + +// SYSCOIN +func isSyscoinNativeAsset(assetGuid string) bool { + return strings.TrimSpace(assetGuid) == "123456" } func (s *PublicServer) parseTemplates() []*template.Template { templateFuncMap := template.FuncMap{ - "formatTime": formatTime, - "formatUnixTime": formatUnixTime, - "formatAmount": s.formatAmount, - "formatAmountWithDecimals": formatAmountWithDecimals, - "formatInt64WithDecimals": formatInt64WithDecimals, - "formatPercentage": formatPercentage, - "setTxToTemplateData": setTxToTemplateData, - "formatKeyID": s.formatKeyID, - "formatNFTID": formatNFTID, - "formatContractExplorerURL": s.formatContractExplorerURL, - "formatBaseAssetID": formatBaseAssetID, - "isNFT": isNFT, - "toJSON": toJSON, - "toString": toString, - "formatEncodeBase64": formatEncodeBase64, - } + "timeSpan": timeSpan, + "relativeTime": relativeTime, + "unixTimeSpan": unixTimeSpan, + "amountSpan": s.amountSpan, + "tokenAmountSpan": s.tokenAmountSpan, + "amountSatsSpan": s.amountSatsSpan, + "formattedAmountSpan": s.formattedAmountSpan, + "summaryValuesSpan": s.summaryValuesSpan, + "addressAlias": addressAlias, + "addressAliasSpan": addressAliasSpan, + "formatAmount": s.formatAmount, + "formatAmountWithDecimals": formatAmountWithDecimals, + "formatInt64": formatInt64, + "formatInt": formatInt, + "formatUint32": formatUint32, + "formatBigInt": formatBigInt, + "formatNFTID": formatNFTID, // SYSCOIN + "formatBaseAssetID": formatBaseAssetID, // SYSCOIN + "formatContractExplorerURL": s.formatContractExplorerURL, // SYSCOIN + "isNFT": isNFT, // SYSCOIN + "isSyscoinShortcut": isSyscoinShortcut, // SYSCOIN + "isSyscoinNativeAsset": isSyscoinNativeAsset, // SYSCOIN + "formatEncodeBase64": formatEncodeBase64, // SYSCOIN + "setTxToTemplateData": setTxToTemplateData, + "feePerByte": feePerByte, + "isOwnAddress": isOwnAddress, + "toJSON": toJSON, + "tokenTransfersCount": tokenTransfersCount, + "tokenCount": tokenCount, + "hasPrefix": strings.HasPrefix, + "jsStr": jsStr, + } + applyTemplateFuncs(templateFuncMap) var createTemplate func(filenames ...string) *template.Template if s.debug { createTemplate = func(filenames ...string) *template.Template { @@ -497,7 +661,7 @@ func (s *PublicServer) parseTemplates() []*template.Template { } t := template.New(filepath.Base(filenames[0])).Funcs(templateFuncMap) for _, filename := range filenames { - b, err := ioutil.ReadFile(filename) + b, err := os.ReadFile(filename) if err != nil { panic(err) } @@ -520,134 +684,354 @@ 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[assetTpl] = createTemplate("./static/templates/asset.html", "./static/templates/txdetail.html", "./static/templates/paging.html", "./static/templates/base.html") - t[assetsTpl] = createTemplate("./static/templates/assets.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[assetTpl] = createTemplate("./static/templates/asset.html", txDetailTemplate, "./static/templates/paging.html", "./static/templates/base.html") // SYSCOIN + t[assetsTpl] = createTemplate("./static/templates/assets.html", "./static/templates/paging.html", "./static/templates/base.html") // SYSCOIN + 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 formatUnixTime(ut int64) string { - return formatTime(time.Unix(ut, 0)) -} - -func formatTime(t time.Time) string { - return t.Format(time.RFC1123) -} - -func toJSON(data interface{}) string { - json, err := json.Marshal(data) - if err != nil { - return "" +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 string(json) + } -// for now return the string as it is -// in future could be used to do coin specific formatting -func (s *PublicServer) formatAmount(a *bchain.Amount) string { +func (s *PublicServer) formatAmount(a *api.Amount) string { if a == nil { return "0" } return s.chainParser.AmountToDecimalString((*big.Int)(a)) } -func formatAmountWithDecimals(a *bchain.Amount, d int) string { - if a == nil { - return "0" +// SYSCOIN +func (s *PublicServer) formatContractExplorerURL(contract string) string { + if contract == "" { + return "" } - return a.DecimalString(d) -} - -func formatInt64WithDecimals(a int64, d int) string { - amount := (*bchain.Amount)(big.NewInt(a)) - return amount.DecimalString(d) -} - -func toString(value interface{}) string { - switch v := value.(type) { - case string: - return v - case []uint8: - return string(v) - default: - return "" - } -} - -func (s *PublicServer) formatContractExplorerURL(token string) string { - return s.chain.GetContractExplorerBaseURL() + "/token/" + token + chain, ok := s.chain.(interface { + GetContractExplorerBaseURL() string + }) + if !ok { + return contract + } + base := chain.GetContractExplorerBaseURL() + if base == "" { + return contract + } + return strings.TrimRight(base, "/") + "/token/" + contract } +// SYSCOIN func formatNFTID(value interface{}) uint64 { - asset := toString(value) - var err error - assetGuid, err := strconv.ParseUint(asset, 10, 64) + assetGuid, err := strconv.ParseUint(fmt.Sprint(value), 10, 64) if err != nil { return 0 } return assetGuid >> 32 } +// SYSCOIN func formatBaseAssetID(value interface{}) uint64 { - asset := toString(value) - var err error - assetGuid, err := strconv.ParseUint(asset, 10, 64) + assetGuid, err := strconv.ParseUint(fmt.Sprint(value), 10, 64) if err != nil { return 0 } return assetGuid & 0xffffffff } +// SYSCOIN +func isNFT(guid string) bool { + assetGuid, err := strconv.ParseUint(guid, 10, 64) + if err != nil { + return false + } + return (assetGuid >> 32) > 0 +} + +// SYSCOIN func formatEncodeBase64(value []byte) string { return base64.StdEncoding.EncodeToString(value) } +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) + 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 { + currentSecondary := formatSecondaryAmount(p*td.CurrentSecondaryCoinRate, td) + txSecondary := "" + // if tx is specified, compute secondary amount is at the time of tx and amount with current rate is returned with class "csec-amt" + if td.Tx != nil { + if td.TxTicker == nil { + date := time.Unix(td.Tx.Blocktime, 0).UTC() + secondary := strings.ToLower(td.SecondaryCoin) + 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 + td.TxDate = date.Add(-1 * time.Second).Format("2006-01-02") + td.TxTicker = ticker + } + } + if td.TxSecondaryCoinRate != 0 { + txSecondary = formatSecondaryAmount(p*td.TxSecondaryCoinRate, td) + } + } + if txSecondary != "" { + appendAmountSpan(&rv, "sec-amt", txSecondary, td.SecondaryCoin, td.TxDate) + appendAmountSpan(&rv, "csec-amt", currentSecondary, td.SecondaryCoin, "") + } else { + appendAmountSpan(&rv, "sec-amt", currentSecondary, td.SecondaryCoin, "") + } + } + } + rv.WriteString("") + return template.HTML(rv.String()) +} + +func (s *PublicServer) amountSatsSpan(a *api.Amount, td *TemplateData, classes string) template.HTML { + var sats string + if s.chainParser.GetChainType() == bchain.ChainEthereumType { + sats = a.DecimalString(9) // Gwei + } else { + sats = a.String() + } + var rv strings.Builder + rv.WriteString(``) + appendAmountSpan(&rv, "", sats, "", "") + rv.WriteString("") + return template.HTML(rv.String()) +} + +func (s *PublicServer) tokenAmountSpan(t *api.TokenTransfer, td *TemplateData, classes string) template.HTML { + primary := formatAmountWithDecimals(t.Value, t.Decimals) + var rv strings.Builder + appendAmountWrapperSpan(&rv, primary, t.Symbol, classes) + appendAmountSpan(&rv, "prim-amt", primary, t.Symbol, "") + if td.SecondaryCoin != "" { + var currentBase, currentSecondary, txBase, txSecondary string + p, err := strconv.ParseFloat(primary, 64) + if err == nil { + if td.CurrentTicker != nil { + // get rate from current ticker + baseRateCurrent, found := td.CurrentTicker.GetTokenRate(t.Contract) + if found { + base := p * float64(baseRateCurrent) + currentBase = strconv.FormatFloat(base, 'f', 6, 64) + currentSecondary = formatSecondaryAmount(base*td.CurrentSecondaryCoinRate, td) + // get the historical rate only if current rate exist + // it is very costly to search in DB in vain for a rate for token for which there are no exchange rates + baseRate, found := s.api.GetContractBaseRate(td.TxTicker, t.Contract, td.Tx.Blocktime) + if found { + base := p * baseRate + txBase = strconv.FormatFloat(base, 'f', 6, 64) + txSecondary = formatSecondaryAmount(base*td.TxSecondaryCoinRate, td) + } + } + } + } + if txBase != "" { + appendAmountSpan(&rv, "base-amt", txBase, td.CoinShortcut, td.TxDate) + if currentBase != "" { + appendAmountSpan(&rv, "cbase-amt", currentBase, td.CoinShortcut, "") + } + } else if currentBase != "" { + appendAmountSpan(&rv, "base-amt", currentBase, td.CoinShortcut, "") + } + if txSecondary != "" { + appendAmountSpan(&rv, "sec-amt", txSecondary, td.SecondaryCoin, td.TxDate) + if currentSecondary != "" { + appendAmountSpan(&rv, "csec-amt", currentSecondary, td.SecondaryCoin, "") + } + } else if currentSecondary != "" { + appendAmountSpan(&rv, "sec-amt", currentSecondary, td.SecondaryCoin, "") + } else { + appendAmountSpan(&rv, "sec-amt", "-", "", "") + } + } + rv.WriteString("") + return template.HTML(rv.String()) +} -func formatPercentage(a uint16) string { - f := float64(a) / 1000.0 - return fmt.Sprintf("%.3f%%", f) +func (s *PublicServer) formattedAmountSpan(a *api.Amount, d int, symbol string, td *TemplateData, classes string) template.HTML { + if symbol == td.CoinShortcut { + d = s.chainParser.AmountDecimals() + } + value := formatAmountWithDecimals(a, d) + var rv strings.Builder + appendAmountSpan(&rv, classes, value, symbol, "") + return template.HTML(rv.String()) } -func (s *PublicServer) formatKeyID(addrBytes []byte) string { - addr, err := s.chainParser.WitnessPubKeyHashFromKeyID(addrBytes) - if err != nil { - glog.Error(err) +func (s *PublicServer) summaryValuesSpan(baseValue float64, secondaryValue float64, td *TemplateData) template.HTML { + var rv strings.Builder + if secondaryValue > 0 { + appendAmountSpan(&rv, "", formatSecondaryAmount(secondaryValue, td), td.SecondaryCoin, "") + if baseValue > 0 && s.chainParser.GetChainType() == bchain.ChainEthereumType { + rv.WriteString(`(`) + appendAmountSpan(&rv, "", strconv.FormatFloat(baseValue, 'f', 6, 64), td.CoinShortcut, "") + rv.WriteString(")") + } + } else { + if baseValue > 0 { + appendAmountSpan(&rv, "", strconv.FormatFloat(baseValue, 'f', 6, 64), td.CoinShortcut, "") + } else { + if td.SecondaryCoin != "" { + rv.WriteString("-") + } + } + } + return template.HTML(rv.String()) +} + +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 getAddressAlias(a string, td *TemplateData) *api.AddressAlias { + var alias api.AddressAlias + var found bool + if td.Block != nil { + alias, found = td.Block.AddressAliases[a] + } else if td.Address != nil { + alias, found = td.Address.AddressAliases[a] + } else if td.Tx != nil { + alias, found = td.Tx.AddressAliases[a] + } + if !found { + return nil + } + return &alias +} + +func addressAlias(a string, td *TemplateData) string { + alias := getAddressAlias(a, td) + if alias == nil { return "" } - return addr + return alias.Alias } -func isNFT(guid string) bool { - var err error - assetGuid, err := strconv.ParseUint(guid, 10, 64) - if err != nil { - return false +func addressAliasSpan(a string, td *TemplateData) template.HTML { + var rv strings.Builder + alias := getAddressAlias(a, td) + if alias == nil { + rv.WriteString(``) + rv.WriteString(html.EscapeString(a)) + } else { + rv.WriteString(``) + rv.WriteString(html.EscapeString(alias.Alias)) } - return (assetGuid >> 32) > 0 + rv.WriteString("") + return template.HTML(rv.String()) } // called from template to support txdetail.html functionality func setTxToTemplateData(td *TemplateData, tx *api.Tx) *TemplateData { td.Tx = tx + // reset the TxTicker if different Blocktime + if td.TxTicker != nil && td.TxTicker.Timestamp.Unix() != tx.Blocktime { + td.TxSecondaryCoinRate = 0 + td.TxTicker = nil + } return td } +// feePerByte returns fee per vByte or Byte if vsize is unknown +func feePerByte(tx *api.Tx) string { + if tx.FeesSat != nil { + if tx.VSize > 0 { + return fmt.Sprintf("%.2f sat/vByte", float64(tx.FeesSat.AsInt64())/float64(tx.VSize)) + } + if tx.Size > 0 { + return fmt.Sprintf("%.2f sat/Byte", float64(tx.FeesSat.AsInt64())/float64(tx.Size)) + } + } + return "" +} + +// isOwnAddress returns true if the address is the one that is being shown in the explorer +func isOwnAddress(td *TemplateData, a string) bool { + return a == td.AddrStr +} + +// called from template, returns count of token transfers of given type in a tx +func tokenTransfersCount(tx *api.Tx, t bchain.TokenStandardName) int { + count := 0 + for i := range tx.TokenTransfers { + if tx.TokenTransfers[i].Standard == t { + count++ + } + } + return count +} + +// called from template, returns count of tokens in array of given type +func tokenCount(tokens []api.Token, t bchain.TokenStandardName) int { + count := 0 + for i := range tokens { + if tokens[i].Standard == t { + count++ + } + } + return count +} + +// jsStr renders s as a complete, quoted JS string literal for use in a JS expression context (e.g. inside 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-
`, + }, + }, + { + name: "explorerAddress " + dbtestdata.EthAddr5d, + r: newGetRequest(ts.URL + "/address/" + dbtestdata.EthAddr5d), + status: http.StatusOK, + contentType: "text/html; charset=utf-8", + body: []string{ + `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
`, + }, + }, + { + name: "explorerTx " + dbtestdata.EthTxidB1T2, + r: newGetRequest(ts.URL + "/tx/0x" + dbtestdata.EthTxidB1T2), + status: http.StatusOK, + contentType: "text/html; charset=utf-8", + body: []string{ + `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
StandardERC20
`}, + }, + { + name: "apiIndex", + r: newGetRequest(ts.URL + "/api"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"blockbook":{"coin":"Fakecoin"`, + `"bestHeight":4321001`, + `"decimals":18`, + `"backend":{"chain":"fakecoin","blocks":2,"headers":2,"bestBlockHash":"0x2b57e15e93a0ed197417a34c2498b7187df79099572c04a6b6e6ff418f74e6ee"`, + `"version":"001001","subversion":"/Fakecoin:0.0.1/"`, + }, + }, + { + name: "apiAddress EthAddr4b", + r: newGetRequest(ts.URL + "/api/v2/address/" + dbtestdata.EthAddr4b), + 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","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"}]}`, + }, + }, + { + // fresh address with no indexed data: gated-on returns confirmedNonce:"0" (synthesized + // locally, no backend call), mirroring the always-present pending nonce:"0". + name: "apiAddress fresh address confirmedNonce=true", + r: newGetRequest(ts.URL + "/api/v2/address/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee?confirmedNonce=true"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `"nonce":"0","confirmedNonce":"0"`, + }, + }, + { + // same fresh address, gated-off: confirmedNonce must be omitted entirely. The full-body + // match fails if confirmedNonce is spuriously inserted after nonce. + name: "apiAddress fresh address gated-off omits confirmedNonce", + r: newGetRequest(ts.URL + "/api/v2/address/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `"address":"0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE","balance":"123450238","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":0,"nonce":"0"}`, + }, + }, + { + name: "apiAddress EthAddr7b details=txs", + r: newGetRequest(ts.URL + "/api/v2/address/" + dbtestdata.EthAddr7b + "?details=txs&confirmedNonce=true"), + 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","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","confirmedNonce":"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"}}}`, + }, + }, + { + name: "apiTx EthTxidB1T2", + r: newGetRequest(ts.URL + "/api/v2/tx/0x" + dbtestdata.EthTxidB1T2), + 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","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"}}}`, + }, + }, + { + name: "apiFiatRates get rate by timestamp", + r: newGetRequest(ts.URL + "/api/v2/tickers?currency=usd×tamp=1574340000"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"ts":1574380800,"rates":{"usd":7914.5}}`, + }, + }, + { + name: "apiFiatRates get token rate by timestamp", + r: newGetRequest(ts.URL + "/api/v2/tickers?currency=usd×tamp=1574340000&token=0xA4DD6Bc15Be95Af55f0447555c8b6aA3088562f3"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"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.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"ts":1574380800,"rates":{"eur":1.0816754,"usd":1.2}}`, + }, + }, + { + name: "apiFiatRates get token rate for unknown token by timestamp", + r: newGetRequest(ts.URL + "/api/v2/tickers?currency=usd×tamp=1574340000&token=0xFFFFFFFFFFe95Af55f0447555c8b6aA3088562f3"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"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() + defer wb.Destroy() + if err := d.StoreFourByteSignature(wb, 2835717307, 145, &bchain.FourByteSignature{ + Name: "transfer", + Parameters: []string{"address", "uint256"}, + }); err != nil { + return err + } + return d.WriteBatch(wb) +} + +// initTestFiatRatesEthereumType initializes test data for /api/v2/tickers endpoint +func initTestFiatRatesEthereumType(d *db.RocksDB) error { + if err := insertFiatRate("20180320000000", map[string]float32{ + "usd": 2000.0, + "eur": 1300.0, + }, map[string]float32{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": 2000.1, + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": 123.0, + }, d); err != nil { + return err + } + if err := insertFiatRate("20180321000000", map[string]float32{ + "usd": 2001.0, + "eur": 1301.0, + }, map[string]float32{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": 2001.1, + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": 199.0, + }, d); err != nil { + return err + } + if err := insertFiatRate("20180322000000", map[string]float32{ + "usd": 2002.0, + "eur": 1302.0, + }, map[string]float32{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": 2002.1, + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": 99.0, + }, d); err != nil { + return err + } + if err := insertFiatRate("20180323000000", map[string]float32{ + "usd": 2003.0, + "eur": 1303.0, + }, map[string]float32{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": 2003.1, + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": 101.0, + }, d); err != nil { + return err + } + if err := insertFiatRate("20190321000000", map[string]float32{ + "usd": 7814.5, + "eur": 7100.0, + }, map[string]float32{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": 7814.1, + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": 499.0, + "0xa4dd6bc15be95af55f0447555c8b6aa3088562f3": 0.8, + }, d); err != nil { + return err + } + 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); 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) { + 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() + // take the handler of the public server and pass it to the test server + ts := httptest.NewServer(s.https.Handler) + 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 09c38a5c2c..71f490eb0d 100644 --- a/server/public_test.go +++ b/server/public_test.go @@ -4,11 +4,14 @@ package server import ( "encoding/json" - "io/ioutil" + "errors" + "io" + "net" "net/http" "net/http/httptest" "net/url" "os" + "reflect" "strconv" "strings" "testing" @@ -16,14 +19,15 @@ import ( "github.com/golang/glog" "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/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/bchain/coins/btc" - "github.com/syscoin/blockbook/common" - "github.com/syscoin/blockbook/db" - "github.com/syscoin/blockbook/tests/dbtestdata" + "github.com/trezor/blockbook/api" + "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" ) func TestMain(m *testing.M) { @@ -36,21 +40,29 @@ 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(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, 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) } d.SetInternalState(is) - block1 := dbtestdata.GetTestBitcoinTypeBlock1(parser) + // there are 2 simulated block, of height bestBlockHeight-1 and bestBlockHeight + bestHeight, err := chain.GetBestBlockHeight() + if err != nil { + t.Fatal(err) + } + block1, err := chain.GetBlock("", bestHeight-1) + if err != nil { + t.Fatal(err) + } // setup internal state BlockTimes for i := uint32(0); i < block1.Height; i++ { is.BlockTimes = append(is.BlockTimes, 0) @@ -59,42 +71,64 @@ func setupRocksDB(t *testing.T, parser bchain.BlockChainParser) (*db.RocksDB, *c if err := d.ConnectBlock(block1); err != nil { t.Fatal(err) } - block2 := dbtestdata.GetTestBitcoinTypeBlock2(parser) - if err := d.ConnectBlock(block2); err != nil { + block2, err := chain.GetBlock("", bestHeight) + if err != nil { t.Fatal(err) } - if err := InitTestFiatRates(d); err != nil { + if err := d.ConnectBlock(block2); err != nil { t.Fatal(err) } is.FinishedSync(block2.Height) + if parser.GetChainType() == bchain.ChainEthereumType { + if err := initTestFiatRatesEthereumType(d); err != nil { + t.Fatal(err) + } + if err := initEthereumTypeDB(d); err != nil { + t.Fatal(err) + } + } else { + if err := initTestFiatRates(d); err != nil { + t.Fatal(err) + } + } return d, is, tmp } -func setupPublicHTTPServer(t *testing.T) (*PublicServer, string) { - parser := btc.NewBitcoinParser( - btc.GetChainParams("test"), - &btc.Configuration{ - BlockAddressesToKeep: 1, - XPubMagic: 70617039, - XPubMagicSegwitP2sh: 71979618, - XPubMagicSegwitNative: 73342198, - Slip44: 1, - }) +var metrics *common.Metrics - d, is, path := setupRocksDB(t, parser) - // 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) +} - metrics, err := common.GetMetrics("Fakecoin") - if err != nil { - glog.Fatal("metrics: ", err) +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}`, } - chain, err := dbtestdata.NewFakeBlockChain(parser) - if err != nil { - glog.Fatal("fakechain: ", err) + // 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" + strconv.FormatBool(extendedIndex)) + if err != nil { + glog.Fatal("metrics: ", err) + } } mempool, err := chain.CreateMempool(chain) @@ -108,8 +142,13 @@ func setupPublicHTTPServer(t *testing.T) (*PublicServer, string) { 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) } @@ -154,78 +193,212 @@ func newPostRequest(u string, body string) *http.Request { return r } -func insertFiatRate(date string, rates map[string]float64, d *db.RocksDB) error { - convertedDate, err := db.FiatRatesConvertDate(date) +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 := time.Parse("20060102150405", date) if err != nil { return err } - ticker := &db.CurrencyRatesTicker{ - Timestamp: convertedDate, - Rates: rates, + ticker := &common.CurrencyRatesTicker{ + Timestamp: convertedDate, + Rates: rates, + TokenRates: tokenRates, + } + wb := grocksdb.NewWriteBatch() + defer wb.Destroy() + if err := d.FiatRatesStoreTicker(wb, ticker); err != nil { + return err } - return d.FiatRatesStoreTicker(ticker) + return d.WriteBatch(wb) } -// InitTestFiatRates initializes test data for /api/v2/tickers endpoint -func InitTestFiatRates(d *db.RocksDB) error { - if err := insertFiatRate("20180320020000", map[string]float64{ +// initTestFiatRates initializes test data for /api/v2/tickers endpoint +func initTestFiatRates(d *db.RocksDB) error { + if err := insertFiatRate("20180320000000", map[string]float32{ "usd": 2000.0, "eur": 1300.0, - }, d); err != nil { + }, nil, d); err != nil { return err } - if err := insertFiatRate("20180320030000", map[string]float64{ + if err := insertFiatRate("20180321000000", map[string]float32{ "usd": 2001.0, "eur": 1301.0, - }, d); err != nil { + }, nil, d); err != nil { return err } - if err := insertFiatRate("20180320040000", map[string]float64{ + if err := insertFiatRate("20180322000000", map[string]float32{ "usd": 2002.0, "eur": 1302.0, - }, d); err != nil { + }, nil, d); err != nil { return err } - if err := insertFiatRate("20180321055521", map[string]float64{ + if err := insertFiatRate("20180324000000", map[string]float32{ "usd": 2003.0, "eur": 1303.0, - }, d); err != nil { + }, nil, d); err != nil { return err } - if err := insertFiatRate("20191121140000", map[string]float64{ + if err := insertFiatRate("20191121000000", map[string]float32{ "usd": 7814.5, "eur": 7100.0, - }, d); err != nil { + }, nil, d); err != nil { return err } - return insertFiatRate("20191121143015", map[string]float64{ + return insertFiatRate("20191122000000", map[string]float32{ "usd": 7914.5, "eur": 7134.1, - }, d) + }, nil, d) +} + +type httpTests struct { + name string + r *http.Request + status int + contentType string + 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) { + 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\n%v\nwant to contain %v", b, c) + break + } + } + }) + } +} + +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 TestReadSendTxRequestFromBody(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 := readSendTxRequestFromBody(strings.NewReader("123456"), maxBodyLen) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Hex != "123456" { + t.Fatalf("got %q, want %q", got.Hex, "123456") + } + }) + + t.Run("rejects body larger than limit by one byte", func(t *testing.T) { + _, err := readSendTxRequestFromBody(strings.NewReader("1234567"), maxBodyLen) + assertAPIError(t, err, "Tx blob too large") + }) } func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { - tests := []struct { - name string - r *http.Request - status int - contentType string - body []string - }{ + tests := []httpTests{ { name: "explorerTx", r: newGetRequest(ts.URL + "/tx/fdd824a780cbb718eeb766eb05d83fdefc793a27082cd5e67f856d69798cf7db"), status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Fake Coin Explorer`, - `

Transaction

`, - `fdd824a780cbb718eeb766eb05d83fdefc793a27082cd5e67f856d69798cf7db`, - `td class="data">0 FAKE`, - `mzVznVsCHkVHX9UN8WPFASWUUHtxnNn4Jj`, - `13.60030331 FAKE`, - `No Inputs (Newly Generated Coins)`, - ``, + `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×
`, }, }, { @@ -234,18 +407,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Fake Coin Explorer`, - `

Address`, - `0.00012345 FAKE`, - `mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz`, - `0.00012345 FAKE`, - `7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25`, - `3172.83951061 FAKE `, - `mtR97eM2HPWVM6c8FGLGcukgaHHQv7THoL`, - `td>mtR97eM2HPWVM6c8FGLGcukgaHHQv7THoL`, - `9172.83951061 FAKE ×`, - `00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840`, - ``, + `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×
`, }, }, { @@ -254,11 +416,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Fake Coin Explorer`, - `

Transaction

`, - `3d90d15ed026dc45e19ffb52875ed18fa9e8012ad123d7f7212176e2b0ebdb71`, - `0.00000062 FAKE`, - ``, + `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
`, }, }, { @@ -267,10 +425,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Fake Coin Explorer`, - `

Error

`, - `

Transaction not found

`, - ``, + `Trezor Fake Coin Explorer

Error

Transaction not found

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

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

Block 225494

`, - `00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6`, - `4`, // number of transactions - `mtR97eM2HPWVM6c8FGLGcukgaHHQv7THoL`, - `9172.83951061 FAKE`, - `fdd824a780cbb718eeb766eb05d83fdefc793a27082cd5e67f856d69798cf7db`, - ``, + `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×
`, }, }, { @@ -311,12 +452,9 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Fake Coin Explorer`, - `

Application status

`, - `

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

`, - `225494`, - `/Fakecoin:0.0.1/`, - ``, + `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
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.
`, }, }, { @@ -325,14 +463,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Fake Coin Explorer`, - `

Block 225494

`, - `00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6`, - `4`, // number of transactions - `mtR97eM2HPWVM6c8FGLGcukgaHHQv7THoL`, - `9172.83951061 FAKE`, - `fdd824a780cbb718eeb766eb05d83fdefc793a27082cd5e67f856d69798cf7db`, - ``, + `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×
`, }, }, { @@ -341,14 +472,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Fake Coin Explorer`, - `

Block 225494

`, - `00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6`, - `4`, // number of transactions - `mtR97eM2HPWVM6c8FGLGcukgaHHQv7THoL`, - `9172.83951061 FAKE`, - `fdd824a780cbb718eeb766eb05d83fdefc793a27082cd5e67f856d69798cf7db`, - ``, + `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×
`, }, }, { @@ -357,14 +481,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Fake Coin Explorer`, - `

Transaction

`, - `fdd824a780cbb718eeb766eb05d83fdefc793a27082cd5e67f856d69798cf7db`, - `td class="data">0 FAKE`, - `mzVznVsCHkVHX9UN8WPFASWUUHtxnNn4Jj`, - `13.60030331 FAKE`, - `No Inputs (Newly Generated Coins)`, - ``, + `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×
`, }, }, { @@ -373,18 +490,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Fake Coin Explorer`, - `

Address`, - `0.00012345 FAKE`, - `mtGXQvBowMkBpnhLckhxhbwYK44Gs9eEtz`, - `0.00012345 FAKE`, - `7c3be24063f268aaa1ed81b64776798f56088757641a34fb156c4f51ed2e9d25`, - `3172.83951061 FAKE `, - `mtR97eM2HPWVM6c8FGLGcukgaHHQv7THoL`, - `td>mtR97eM2HPWVM6c8FGLGcukgaHHQv7THoL`, - `9172.83951061 FAKE ×`, - `00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840`, - ``, + `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×
`, }, }, { @@ -393,16 +499,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Fake Coin Explorer`, - `

XPUB 1186.419755 FAKE

upub5E1xjDmZ7Hhej6LPpS8duATdKXnRYui7bDYj6ehfFGzWDZtmCmQkZhc3Zb7kgRLtHWd16QFxyP86JKL3ShZEBFX88aciJ3xyocuyhZZ8g6q
`, - `Total Received1186.41975501 FAKE`, - `Total Sent0.00000001 FAKE`, - `Used XPUB Addresses2`, - `2N6utyMZfPNUb1Bk8oz7p2JqJrXkq83gegu1186.419755 FAKE1m/49'/1'/33'/1/3`, - ``, - ``, - `2NEVv9LJmAnY99W1pFoc5UJjVdypBqdnvu10.00009876 FAKE `, - ``, + `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

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

XPUB 0 FAKE

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

Confirmed

`, - `Total Received0 FAKE`, - `Total Sent0 FAKE`, - `Used XPUB Addresses0`, - ``, + `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
`, }, }, { @@ -425,10 +517,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "text/html; charset=utf-8", body: []string{ - `Fake Coin Explorer`, - `

Error

`, - `

No matching records found for '1234'

`, - ``, + `Trezor Fake Coin Explorer

Error

No matching records found for '1234'

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

Send Raw Transaction

`, - ``, - ``, + `Trezor Fake Coin Explorer

Send Raw Transaction

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

Send Raw Transaction

`, - ``, - `
Invalid data
`, - ``, + `Trezor Fake Coin Explorer

Send Raw Transaction

Invalid data
`, }, }, { @@ -533,12 +615,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}}`, }, }, { @@ -547,16 +629,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}}`, }, }, { @@ -592,25 +674,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}}]`, }, }, { @@ -619,7 +701,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}}`, }, }, { @@ -628,7 +710,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}}`, }, }, { @@ -637,7 +719,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}}`, }, }, { @@ -655,7 +737,52 @@ 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."}`, + }, + }, + { + name: "apiMultiFiatRates timestamp limit", + r: newGetRequest(ts.URL + "/api/v2/multi-tickers?timestamp=" + strings.Repeat("1,", api.MaxFiatRatesTimestamps) + "1¤cy=usd"), + status: http.StatusBadRequest, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"error":"too many timestamps, max ` + strconv.Itoa(api.MaxFiatRatesTimestamps) + `"}`, }, }, { @@ -682,7 +809,7 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { status: http.StatusOK, contentType: "application/json; charset=utf-8", body: []string{ - `{"address":"mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw","balance":"0","totalReceived":"1234567890123","totalSent":"1234567890123","unconfirmedBalance":"0","unconfirmedTxs":0,"txs":2}`, + `{"address":"mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw","balance":"0","totalReceived":"1234567890123","totalSent":"1234567890123","unconfirmedTxs":0,"txs":2}`, }, }, { @@ -709,7 +836,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"}]}`, }, }, { @@ -718,7 +845,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"}]}`, }, }, { @@ -727,7 +854,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}]}`, }, }, { @@ -736,7 +863,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}]}`, }, }, { @@ -745,7 +872,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","unconfirmedTxs":0,"txs":3,"addrTxCount":3,"usedTokens":2}`, }, }, { @@ -754,7 +881,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}]}`, }, }, { @@ -763,7 +890,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"}]}`, }, }, { @@ -772,7 +899,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}]}`, }, }, { @@ -826,7 +953,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}}]`, }, }, { @@ -835,7 +962,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}}]`, }, }, { @@ -844,7 +971,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}}]`, }, }, { @@ -862,7 +989,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}}]`, }, }, { @@ -889,7 +1016,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}}]`, }, }, { @@ -911,30 +1038,21 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { }, }, { - name: "apiSendTx POST JSON", - r: newPostRequest(ts.URL+"/api/v2/sendtx/", `{"hex":"123456"}`), - status: http.StatusOK, - contentType: "application/json; charset=utf-8", - body: []string{ - `{"result":"9876"}`, - }, - }, - { - name: "apiSendTx POST JSON maxburn unsupported", - r: newPostRequest(ts.URL+"/api/v2/sendtx/", `{"hex":"123456","maxburnamount":"1"}`), + name: "apiSendTx POST empty", + r: newPostRequest(ts.URL+"/api/v2/sendtx", ""), status: http.StatusBadRequest, contentType: "application/json; charset=utf-8", body: []string{ - `{"error":"maxfeerate and maxburnamount are supported only on Syscoin"}`, + `{"error":"Missing tx blob"}`, }, }, { - name: "apiSendTx POST empty", - r: newPostRequest(ts.URL+"/api/v2/sendtx", ""), + 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":"Missing tx blob"}`, + `{"error":"Tx blob too large"}`, }, }, { @@ -965,568 +1083,656 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) { }, }, } + performHttpTests(tests, t, ts) +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - 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 := ioutil.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 - } - } - }) +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 socketioTestsBitcoinType(t *testing.T, ts *httptest.Server) { - type socketioReq struct { - Method string `json:"method"` - Params []interface{} `json:"params"` +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() - - 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 wallet https://trezor.io/. 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"}}`, - }, + var resp websocketRespWithData + if err := json.Unmarshal(message, &resp); err != nil { + t.Fatal(err) } + return resp +} - 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 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) } } -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_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) } - type websocketResp struct { - ID string `json:"id"` + resp := readWebsocketResponse(t, ws, time.Second) + if resp.ID != "0" { + t.Fatalf("got response id %q, want %q", resp.ID, "0") } - url := strings.Replace(ts.URL, "http://", "ws://", 1) + "/websocket" - s, _, err := websocket.DefaultDialer.Dial(url, nil) - if err != nil { + + payload := strings.Repeat("a", int(maxWebsocketMessageBytes)+1) + if err := ws.WriteMessage(websocket.TextMessage, []byte(payload)); 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":{"utxos":[{"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":{"utxos":[]}}`, - }, - { - 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{}, - }, - }, - 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":"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":{"ts":1574346615,"rates":{"does-not-exist":-1}}}`, - }, - { - 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"}, + 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 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) + } + } + 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) + } + } + + if err := ws.WriteJSON(websocketReq{ID: "overflow", Method: "ping"}); 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 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":"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":"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":"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"}}}`, + }, + { + name: "websocket getFiatRatesForTimestamps timestamp limit", + req: websocketReq{ + Method: "getFiatRatesForTimestamps", + Params: map[string]interface{}{ + "currencies": []string{"usd"}, + "timestamps": make([]int64, api.MaxFiatRatesTimestamps+1), + }, + }, + want: `{"id":"45","data":{"error":{"message":"too many timestamps, max ` + strconv.Itoa(api.MaxFiatRatesTimestamps) + `"}}}`, + }, +} + +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 { @@ -1575,8 +1781,102 @@ func websocketTestsBitcoinType(t *testing.T, ts *httptest.Server) { } } +// fixedTimeNow returns always 2022-09-15 12:43:56 UTC +func fixedTimeNow() time.Time { + return time.Date(2022, 9, 15, 12, 43, 56, 0, time.UTC) +} + +func setupChain(t *testing.T) (bchain.BlockChainParser, bchain.BlockChain) { + timeNow = fixedTimeNow + parser := btc.NewBitcoinParser( + btc.GetChainParams("test"), + &btc.Configuration{ + BlockAddressesToKeep: 1, + XPubMagic: 70617039, + XPubMagicSegwitP2sh: 71979618, + XPubMagicSegwitNative: 73342198, + Slip44: 1, + }) + + chain, err := dbtestdata.NewFakeBlockChain(parser) + if err != nil { + glog.Fatal("fakechain: ", err) + } + return parser, chain +} + +func Test_PublicServer_OpenAPIDocs(t *testing.T) { + parser, chain := setupChain(t) + + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + get := func(endpoint string) *http.Response { + t.Helper() + resp, err := http.DefaultClient.Do(newGetRequest(ts.URL + endpoint)) + if err != nil { + t.Fatal(err) + } + return resp + } + + resp := get("/api-docs/") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("/api-docs/ StatusCode = %v, want %v", resp.StatusCode, http.StatusOK) + } + if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Fatalf("/api-docs/ Content-Type = %q", ct) + } + csp := resp.Header.Get("Content-Security-Policy") + if !strings.Contains(csp, "script-src 'self' https://cdn.jsdelivr.net;") { + t.Fatalf("Swagger CSP missing CDN in script-src: %q", csp) + } + if strings.Contains(csp, "script-src 'self' 'unsafe-inline'") { + t.Fatalf("Swagger CSP must not allow unsafe-inline in script-src: %q", csp) + } + if v := resp.Header.Get("X-Content-Type-Options"); v != "nosniff" { + t.Fatalf("X-Content-Type-Options = %q, want nosniff", v) + } + + for _, p := range []string{"/api-docs/openapi.yaml", "/openapi.yaml"} { + r := get(p) + body, _ := io.ReadAll(r.Body) + r.Body.Close() + if r.StatusCode != http.StatusOK { + t.Fatalf("%s StatusCode = %v", p, r.StatusCode) + } + if ct := r.Header.Get("Content-Type"); ct != "application/yaml; charset=utf-8" { + t.Fatalf("%s Content-Type = %q", p, ct) + } + if !strings.Contains(string(body), "openapi: 3.1.0") { + t.Fatalf("%s body missing openapi: 3.1.0", p) + } + } + + req, err := http.NewRequest(http.MethodPost, ts.URL+"/openapi.yaml", nil) + if err != nil { + t.Fatal(err) + } + r, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + r.Body.Close() + if r.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("POST /openapi.yaml StatusCode = %v, want %v", r.StatusCode, http.StatusMethodNotAllowed) + } + if allow := r.Header.Get("Allow"); allow != "GET, HEAD" { + t.Fatalf("POST /openapi.yaml Allow = %q, want %q", allow, "GET, HEAD") + } +} + func Test_PublicServer_BitcoinType(t *testing.T) { - s, dbpath := setupPublicHTTPServer(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 @@ -1584,6 +1884,942 @@ 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_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) + } + 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_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 + token string + want float32 + }{ + {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}, + }, + } + 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_HTTPBalanceHistory_MaxTxsCap_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() + + // This address has 2 transactions in the fixture. + addr := "2NEVv9LJmAnY99W1pFoc5UJjVdypBqdnvu1" + + // With the cap disabled (0), the full history is returned. + s.is.BalanceHistoryMaxTxsREST = 0 + var full []balanceHistoryResponse + mustGetJSON(t, ts.URL+"/api/v2/balancehistory/"+addr, http.StatusOK, &full) + if len(full) < 2 { + t.Fatalf("expected at least 2 history entries with the cap disabled, got %d", len(full)) + } + + // With a cap below the address's transaction count, the request is rejected + // with a public 400 error rather than doing the unbounded scan. + s.is.BalanceHistoryMaxTxsREST = 1 + var apiErr struct { + Error string `json:"error"` + } + mustGetJSON(t, ts.URL+"/api/v2/balancehistory/"+addr, http.StatusBadRequest, &apiErr) + if !strings.Contains(apiErr.Error, "narrow the from/to range") { + t.Fatalf("unexpected address error message: %q", apiErr.Error) + } + + // The xpub path is capped too, and its public cap error is surfaced + // directly (not masked by the address fallback). + var xpubErr struct { + Error string `json:"error"` + } + mustGetJSON(t, ts.URL+"/api/v2/balancehistory/"+dbtestdata.Xpub, http.StatusBadRequest, &xpubErr) + if !strings.Contains(xpubErr.Error, "xpub spans more than") { + t.Fatalf("unexpected xpub error message: %q", xpubErr.Error) + } +} + +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)}, + }, + } + 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, + }, + TokenRates: map[string]float32{ + token: 4, + }, + } + 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, + }, + TokenRates: map[string]float32{ + token: 5, + }, + }) + 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}, + }, + } + 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, + }, + TokenRates: map[string]float32{ + tronUSDT: 9, + }, + } + 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", + }, + } + 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 httpTestsBitcoinTypeExtendedIndex(t *testing.T, ts *httptest.Server) { + tests := []struct { + name string + r *http.Request + status int + contentType string + body []string + }{ + { + 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: "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: "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: "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: "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: "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: "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) { + 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) + } + }) + } +} + +func Test_countCommaSeparatedValues(t *testing.T) { + tests := []struct { + name string + value string + limit int + want int + }{ + {"empty string", "", api.MaxFiatRatesTimestamps, 0}, + {"single value", "1", api.MaxFiatRatesTimestamps, 1}, + {"stops after limit exceeded", "1,2,3", 2, 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := countCommaSeparatedValues(tt.value, tt.limit); got != tt.want { + t.Errorf("countCommaSeparatedValues(%q, %d) = %d, want %d", tt.value, tt.limit, got, tt.want) + } + }) + } } diff --git a/server/public_tron_test.go b/server/public_tron_test.go new file mode 100644 index 0000000000..caae9b6709 --- /dev/null +++ b/server/public_tron_test.go @@ -0,0 +1,207 @@ +//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`, + `
Staking
`, + `Voting Power7 / 10`, + `href="/address/TDGSR64oU4QDpViKfdwawSiqwyqpUB6JUD"`, + `Unclaimed Reward`, + }, + }, + { + 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 + "?confirmedNonce=true"), + 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","confirmedNonce":"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,"totalEnergyLimit":45500,"totalEnergyWeight":2255,"totalBandwidthLimit":2755,"totalBandwidthWeight":3255,"stakingInfo":{"stakedBalance":"7000000","stakedBalanceEnergy":"5000000","stakedBalanceBandwidth":"2000000","unstakingBatches":[{"amount":"1112757","expireTime":1777018452}],"totalVotingPower":"10","availableVotingPower":"7","votes":[{"address":"TDGSR64oU4QDpViKfdwawSiqwyqpUB6JUD","voteCount":"20"}],"unclaimedReward":"42767","delegatedBalanceEnergy":"3210000","delegatedBalanceBandwidth":"654000"}}}}`, + }, + }, + { + // gated-off: the same address without ?confirmedNonce=true must omit confirmedNonce + // entirely. The asserted substring spans the nonce->tokens boundary, which only matches + // when no confirmedNonce field is inserted after nonce (mirrors the EVM gated-off check). + name: "apiAddress TronAddrTZ gated-off omits confirmedNonce", + r: newGetRequest(ts.URL + "/api/v2/address/" + dbtestdata.TronAddrTZ), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `"nonce":"255","tokens":[{"type":"TRC20","standard":"TRC20","name":"TronTestContract236"`, + }, + }, + { + name: "apiAddress TronAddrTX", + r: newGetRequest(ts.URL + "/api/v2/address/" + dbtestdata.TronAddrTD + "?details=txs&confirmedNonce=true"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `"address":"TDGSR64oU4QDpViKfdwawSiqwyqpUB6JUD"`, + `"chainExtraData":{"payloadType":"tron","payload":{"availableStakedBandwidth":36,"totalStakedBandwidth":1036,"availableFreeBandwidth":536,"totalFreeBandwidth":1536,"availableEnergy":3600,"totalEnergy":13600,"totalEnergyLimit":23600,"totalEnergyWeight":2036,"totalBandwidthLimit":2536,"totalBandwidthWeight":3036,"stakingInfo":{"stakedBalance":"7000000","stakedBalanceEnergy":"5000000","stakedBalanceBandwidth":"2000000","unstakingBatches":[{"amount":"1112757","expireTime":1777018452}],"totalVotingPower":"10","availableVotingPower":"7","votes":[{"address":"TDGSR64oU4QDpViKfdwawSiqwyqpUB6JUD","voteCount":"20"}],"unclaimedReward":"42767","delegatedBalanceEnergy":"3210000","delegatedBalanceBandwidth":"654000"}}`, + `"transactions":[{"txid":"a431984fef1d014620504d02f821f872221cf44c250a81a31e81fa4855b2b302"`, + `"chainExtraData":{"contractType":"TriggerSmartContract","operation":"contractCall","assetIssueID":"1002001","totalFee":"3076500","energyUsage":"14650","energyUsageTotal":"14650","bandwidthUsage":"345","bandwidthFee":"0","result":"SUCCESS"}`, + `"nonce":"36"`, + `"confirmedNonce":"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&confirmedNonce=true"), + 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"`, + `"confirmedNonce":"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", + "confirmedNonce": true, + }, + }, + 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","confirmedNonce":"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,"totalEnergyLimit":45500,"totalEnergyWeight":2255,"totalBandwidthLimit":2755,"totalBandwidthWeight":3255,"stakingInfo":{"stakedBalance":"7000000","stakedBalanceEnergy":"5000000","stakedBalanceBandwidth":"2000000","unstakingBatches":[{"amount":"1112757","expireTime":1777018452}],"totalVotingPower":"10","availableVotingPower":"7","votes":[{"address":"TDGSR64oU4QDpViKfdwawSiqwyqpUB6JUD","voteCount":"20"}],"unclaimedReward":"42767","delegatedBalanceEnergy":"3210000","delegatedBalanceBandwidth":"654000"}}}}}`, + }, +} + +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/rest_rate_limiter.go b/server/rest_rate_limiter.go new file mode 100644 index 0000000000..4c75deba8b --- /dev/null +++ b/server/rest_rate_limiter.go @@ -0,0 +1,541 @@ +package server + +import ( + "fmt" + "math" + "net/http" + "net/netip" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/golang/glog" + "github.com/trezor/blockbook/common" +) + +const ( + defaultRestUIRateLimit = 180 + defaultRestUIRateWindow = time.Minute + defaultRestUIBurst = 40 + defaultRestUIMaxConcurrent = 12 + defaultRestUIStateTTL = 10 * time.Minute + defaultRestUIBlockDuration = 0 + + restUILimiterCleanupInterval = time.Minute + restUIBreachWindow = 10 * time.Minute + restUIBreachBlockThreshold = 3 + // restUIBreachMinSpacing is the minimum quiet gap between counted breaches: + // one burst produces many rejections in the same instant (e.g. a page firing + // dozens of parallel fetches), and the block threshold should mean separate + // abuse episodes, not one spike. + restUIBreachMinSpacing = 10 * time.Second + // restUIMaxTrackedClients bounds the per-key state map so a flood rotating + // client keys cannot grow it (and the sweeps over it) without bound; past the + // cap, new keys are temporarily admitted untracked (fail open) while + // already-tracked keys stay limited. + restUIMaxTrackedClients = 100_000 +) + +const ( + restUIRejectRequestRate = "request_rate" + restUIRejectConcurrentRequests = "concurrent_requests" + restUIRejectIPBlocked = "ip_blocked" +) + +type restUILimiterConfig struct { + rateLimit int + rateWindow time.Duration + burst int + maxConcurrent int + stateTTL time.Duration + blockDuration time.Duration + trustedProxies []netip.Prefix + cloudflareCIDRs []netip.Prefix + trustPseudoIPv6 bool +} + +type restUIRateLimiter struct { + mux sync.Mutex + clients map[string]*restUIClientLimit + lastCleanup time.Time + capWarned bool + metrics *common.Metrics + rateLimit int + rateWindow time.Duration + burst int + maxConcurrent int + stateTTL time.Duration + blockDuration time.Duration + trustedProxies []netip.Prefix + cloudflarePrefixes []netip.Prefix + trustPseudoIPv6 bool + localBypassWarn sync.Once +} + +type restUIClientLimit struct { + active int + bucket restUITokenBucket + breaches []time.Time + blockedUntil time.Time + blockRejected int + lastSeen time.Time + lastRejectLog time.Time + lastRejectKind string +} + +type restUITokenBucket struct { + tokens float64 + lastRefill time.Time +} + +type restUILimitDecision struct { + accepted bool + // untracked marks an accepted request for which no per-key state was + // created (tracking-cap fail-open); the caller must not release it. + untracked bool + reason string + retryAfter time.Duration + // shouldLog is the per-client log-throttling decision for a rejection, + // made inside accept while the client state is at hand so the caller can + // log outside the limiter lock. + shouldLog bool +} + +func newRestUIRateLimiter(network string, metrics *common.Metrics) (*restUIRateLimiter, error) { + cfg, err := readRestUILimiterConfig(network) + if err != nil { + return nil, err + } + if cfg.rateLimit == 0 && cfg.maxConcurrent == 0 { + glog.Info("REST/UI rate limiter disabled") + return nil, nil + } + l := &restUIRateLimiter{ + clients: make(map[string]*restUIClientLimit), + metrics: metrics, + rateLimit: cfg.rateLimit, + rateWindow: cfg.rateWindow, + burst: cfg.burst, + maxConcurrent: cfg.maxConcurrent, + stateTTL: cfg.stateTTL, + blockDuration: cfg.blockDuration, + trustedProxies: cfg.trustedProxies, + cloudflarePrefixes: cfg.cloudflareCIDRs, + trustPseudoIPv6: cfg.trustPseudoIPv6, + } + if metrics != nil { + metrics.RestUIActiveIPs.Set(0) + metrics.RestUIMaxActiveRequestsPerIP.Set(0) + metrics.RestUIBlockedIPs.Set(0) + } + if cfg.rateLimit > 0 { + glog.Infof("REST/UI rate limit: %d requests / %s; burst: %d", cfg.rateLimit, cfg.rateWindow, cfg.burst) + } else { + glog.Info("REST/UI request-rate limit disabled") + } + if cfg.maxConcurrent > 0 { + glog.Infof("REST/UI per-client concurrency limit: %d active requests", cfg.maxConcurrent) + } else { + glog.Info("REST/UI per-client concurrency limit disabled") + } + if cfg.blockDuration > 0 { + glog.Infof("REST/UI temporary IP block enabled after repeated breaches: %s", cfg.blockDuration) + if len(cfg.cloudflareCIDRs) == 0 { + glog.Warning("REST/UI temporary IP block is enabled without Cloudflare peer verification; CF-Connecting-* derived addresses are not blockable in this mode") + } + } + go l.runMaintenance(restUILimiterCleanupInterval) + return l, nil +} + +func readRestUILimiterConfig(network string) (restUILimiterConfig, error) { + prefix := strings.ToUpper(network) + cfg := restUILimiterConfig{ + rateLimit: defaultRestUIRateLimit, + rateWindow: defaultRestUIRateWindow, + burst: defaultRestUIBurst, + maxConcurrent: defaultRestUIMaxConcurrent, + stateTTL: defaultRestUIStateTTL, + blockDuration: defaultRestUIBlockDuration, + } + + var err error + if cfg.rateLimit, err = parseNonNegativeIntEnv(prefix+"_REST_UI_RATE_LIMIT", cfg.rateLimit); err != nil { + return cfg, err + } + if cfg.rateWindow, err = parsePositiveDurationEnv(prefix+"_REST_UI_RATE_WINDOW", cfg.rateWindow); err != nil { + return cfg, err + } + if cfg.burst, err = parseNonNegativeIntEnv(prefix+"_REST_UI_BURST", cfg.burst); err != nil { + return cfg, err + } + if cfg.rateLimit > 0 && cfg.burst <= 0 { + return cfg, fmt.Errorf("%s_REST_UI_BURST: invalid value %d (want a positive integer when REST request-rate limiting is enabled)", prefix, cfg.burst) + } + if cfg.maxConcurrent, err = parseNonNegativeIntEnv(prefix+"_REST_UI_MAX_CONCURRENT", cfg.maxConcurrent); err != nil { + return cfg, err + } + if cfg.stateTTL, err = parsePositiveDurationEnv(prefix+"_REST_UI_STATE_TTL", cfg.stateTTL); err != nil { + return cfg, err + } + if cfg.blockDuration, err = parseNonNegativeDurationEnv(prefix+"_REST_UI_BLOCK_DURATION", cfg.blockDuration); err != nil { + return cfg, err + } + + clientIPCfg, err := readClientIPConfig(network) + if err != nil { + return cfg, err + } + cfg.trustedProxies = clientIPCfg.trustedProxies + cfg.cloudflareCIDRs = clientIPCfg.cloudflarePrefixes + cfg.trustPseudoIPv6 = clientIPCfg.trustPseudoIPv6 + return cfg, nil +} + +func parseNonNegativeIntEnv(envName string, defaultValue int) (int, error) { + v := os.Getenv(envName) + if v == "" { + return defaultValue, nil + } + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + return 0, fmt.Errorf("%s: invalid value %q (want a non-negative integer)", envName, v) + } + return n, nil +} + +func parsePositiveDurationEnv(envName string, defaultValue time.Duration) (time.Duration, error) { + v := os.Getenv(envName) + if v == "" { + return defaultValue, nil + } + d, err := time.ParseDuration(v) + if err != nil || d <= 0 { + return 0, fmt.Errorf("%s: invalid duration %q (e.g. \"1m\")", envName, v) + } + return d, nil +} + +func parseNonNegativeDurationEnv(envName string, defaultValue time.Duration) (time.Duration, error) { + v := os.Getenv(envName) + if v == "" { + return defaultValue, nil + } + d, err := time.ParseDuration(v) + if err != nil || d < 0 { + return 0, fmt.Errorf("%s: invalid duration %q (e.g. \"10m\", or \"0\" to disable)", envName, v) + } + return d, nil +} + +func (l *restUIRateLimiter) wrapPublic(next http.Handler, basePath string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isRateLimitedRoute(r.URL.Path, basePath) { + next.ServeHTTP(w, r) + return + } + ip, blockSafe, fromHeader := resolveClientIP(r, l.trustedProxies, l.cloudflarePrefixes, l.trustPseudoIPv6) + if !fromHeader && isLocalOrTrustedProxyIP(ip, l.trustedProxies) { + // Request came straight from the operator's own loopback/LAN/trusted proxy + // with no client-attribution header: the key would be a shared infrastructure + // address, so limiting it would throttle the whole deployment as one client. + l.localBypassWarn.Do(func() { + glog.Info("REST/UI request from local/trusted peer ", ip, + " without a client attribution header; such requests are not rate limited") + }) + next.ServeHTTP(w, r) + return + } + ipKey := rateLimitKey(ip) + // blockKey keeps IPv6 at the full /128 so a temporary block never takes + // out a whole shared /64 (rate limiting still aggregates to /64 via + // ipKey). For IPv4 the two keys are identical. + bKey := "" + blockable := false + if l.blockDuration > 0 { + bKey = blockKey(ip) + blockable = blockSafe && isBlockableKey(ip, l.trustedProxies, l.cloudflarePrefixes) + } + decision := l.accept(ipKey, bKey, blockable, time.Now()) + if !decision.accepted { + l.observeRejection(decision.reason) + if decision.shouldLog { + glog.Warning("REST/UI request rejected, ", ipKey, ", ", decision.reason) + } + writeRestUIRateLimitResponse(w, decision.retryAfter) + return + } + if !decision.untracked { + // Wrap the release so time.Now() is evaluated when the handler + // finishes, not when the defer is registered. + defer func() { l.release(ipKey, time.Now()) }() + } + next.ServeHTTP(w, r) + }) +} + +func isRateLimitedRoute(reqPath, basePath string) bool { + if !strings.HasPrefix(reqPath, basePath) { + return false + } + // Routes are registered by raw concatenation (basePath+"address/" etc.), so rel + // is the registered suffix. splitBinding does not guarantee basePath ends in "/" + // (a "-public=:port/path" binding without a trailing slash yields basePath + // "/path"), yet static assets are still registered via publicPath at + // "/path/favicon.ico"; trim any leading slash so the deny-list matches the + // registered suffix regardless of the binding's trailing-slash shape. + rel := strings.TrimPrefix(strings.TrimPrefix(reqPath, basePath), "/") + switch { + case rel == "favicon.ico", + rel == "openapi.yaml", + rel == "test-websocket.html", + rel == "websocket", + rel == "api-docs", + strings.HasPrefix(rel, "static/"), + strings.HasPrefix(rel, "api-docs/"): + return false + } + return true +} + +func (l *restUIRateLimiter) accept(ipKey, blockKey string, blockable bool, now time.Time) restUILimitDecision { + l.mux.Lock() + defer l.mux.Unlock() + + l.cleanupLocked(now) + + // Block check first, keyed on the (narrower) block key so a per-/128 block is + // enforced without consulting the /64 rate-limit entry. The block entry is + // created only when a breach trips the block (recordBreachLocked), so for a + // client that is not blocked this is a plain lookup that never grows the map. + if bc := l.clients[blockKey]; bc != nil && bc.blockedUntil.After(now) { + bc.lastSeen = now + bc.blockRejected++ + return restUILimitDecision{ + reason: restUIRejectIPBlocked, + retryAfter: bc.blockedUntil.Sub(now), + shouldLog: bc.shouldLogRejection(restUIRejectIPBlocked, now), + } + } + + client := l.clients[ipKey] + if client == nil { + if len(l.clients) >= restUIMaxTrackedClients { + l.sweepLocked(now) + } + if len(l.clients) >= restUIMaxTrackedClients { + if !l.capWarned { + l.capWarned = true + glog.Warning("REST/UI rate limiter is tracking ", restUIMaxTrackedClients, + " client keys; admitting new keys unlimited until the map shrinks") + } + return restUILimitDecision{accepted: true, untracked: true} + } + client = &restUIClientLimit{} + l.clients[ipKey] = client + } + client.lastSeen = now + + if l.maxConcurrent > 0 && client.active >= l.maxConcurrent { + l.recordBreachLocked(blockKey, blockable, now) + return restUILimitDecision{ + reason: restUIRejectConcurrentRequests, + shouldLog: client.shouldLogRejection(restUIRejectConcurrentRequests, now), + } + } + + if l.rateLimit > 0 { + ok, retryAfter := client.bucket.allow(now, l.rateLimit, l.rateWindow, l.burst) + if !ok { + l.recordBreachLocked(blockKey, blockable, now) + return restUILimitDecision{ + reason: restUIRejectRequestRate, + retryAfter: retryAfter, + shouldLog: client.shouldLogRejection(restUIRejectRequestRate, now), + } + } + } + + client.active++ + return restUILimitDecision{accepted: true} +} + +func (l *restUIRateLimiter) release(ipKey string, now time.Time) { + l.mux.Lock() + defer l.mux.Unlock() + + client := l.clients[ipKey] + if client == nil { + return + } + if client.active > 0 { + client.active-- + } + client.lastSeen = now + l.cleanupLocked(now) +} + +func (l *restUIRateLimiter) recordBreachLocked(blockKey string, blockable bool, now time.Time) { + if l.blockDuration <= 0 || !blockable { + return + } + // Breaches and the block accrue on the block key (full /128 for IPv6, == the + // rate-limit key for IPv4) so one address cannot block a shared /64. Respect the + // tracking cap so a flood rotating block keys cannot grow the map (fail open -- + // the /64 rate limiter still throttles them). + client := l.clients[blockKey] + if client == nil { + if len(l.clients) >= restUIMaxTrackedClients { + return + } + client = &restUIClientLimit{} + l.clients[blockKey] = client + } + client.lastSeen = now + cutoff := now.Add(-restUIBreachWindow) + client.breaches = trimTimes(client.breaches, cutoff) + // every rejection of an over-limit client lands here; only count one breach + // per quiet gap so the block threshold means separate abuse episodes + if n := len(client.breaches); n > 0 && now.Sub(client.breaches[n-1]) < restUIBreachMinSpacing { + return + } + client.breaches = append(client.breaches, now) + if len(client.breaches) >= restUIBreachBlockThreshold { + client.blockedUntil = now.Add(l.blockDuration) + client.blockRejected = 0 + client.breaches = client.breaches[:0] + } +} + +func (b *restUITokenBucket) allow(now time.Time, rateLimit int, rateWindow time.Duration, burst int) (bool, time.Duration) { + if rateLimit <= 0 { + return true, 0 + } + if b.lastRefill.IsZero() { + b.tokens = float64(burst) + b.lastRefill = now + } + if now.Before(b.lastRefill) { + now = b.lastRefill + } + ratePerSecond := float64(rateLimit) / rateWindow.Seconds() + b.tokens = math.Min(float64(burst), b.tokens+now.Sub(b.lastRefill).Seconds()*ratePerSecond) + b.lastRefill = now + if b.tokens >= 1 { + b.tokens-- + return true, 0 + } + if ratePerSecond <= 0 { + return false, rateWindow + } + return false, time.Duration(math.Ceil((1 - b.tokens) / ratePerSecond * float64(time.Second))) +} + +func trimTimes(times []time.Time, cutoff time.Time) []time.Time { + i := 0 + for i < len(times) && times[i].Before(cutoff) { + i++ + } + if i > 0 { + copy(times, times[i:]) + times = times[:len(times)-i] + } + return times +} + +func (l *restUIRateLimiter) cleanupLocked(now time.Time) { + if !l.lastCleanup.IsZero() && now.Sub(l.lastCleanup) < restUILimiterCleanupInterval { + return + } + l.sweepLocked(now) +} + +func (l *restUIRateLimiter) sweepLocked(now time.Time) { + l.lastCleanup = now + for ipKey, client := range l.clients { + client.breaches = trimTimes(client.breaches, now.Add(-restUIBreachWindow)) + if client.active == 0 && !client.blockedUntil.After(now) && now.Sub(client.lastSeen) > l.stateTTL { + delete(l.clients, ipKey) + } + } + if l.capWarned && len(l.clients) < restUIMaxTrackedClients/2 { + l.capWarned = false + } +} + +func (l *restUIRateLimiter) sweep(now time.Time) { + l.mux.Lock() + defer l.mux.Unlock() + l.sweepLocked(now) +} + +func (l *restUIRateLimiter) stats(now time.Time) (activeIPs int, maxActiveRequestsPerIP int, blockedIPs int) { + l.mux.Lock() + defer l.mux.Unlock() + for _, client := range l.clients { + if client.active > 0 { + activeIPs++ + if client.active > maxActiveRequestsPerIP { + maxActiveRequestsPerIP = client.active + } + } + if client.blockedUntil.After(now) { + blockedIPs++ + } + } + return activeIPs, maxActiveRequestsPerIP, blockedIPs +} + +func (l *restUIRateLimiter) runMaintenance(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for now := range ticker.C { + l.sweep(now) + if l.metrics != nil { + activeIPs, maxActive, blockedIPs := l.stats(now) + l.metrics.RestUIActiveIPs.Set(float64(activeIPs)) + l.metrics.RestUIMaxActiveRequestsPerIP.Set(float64(maxActive)) + l.metrics.RestUIBlockedIPs.Set(float64(blockedIPs)) + } + } +} + +func (l *restUIRateLimiter) observeRejection(reason string) { + if l.metrics != nil { + l.metrics.RestUIRateLimitRejections.With(common.Labels{"reason": reason}).Inc() + } +} + +// shouldLogRejection throttles per-client rejection logging: log when the +// rejection kind changes or at most once a minute, plus the first and every +// 1000th rejection of a blocked client. Called with the limiter mutex held (it +// mutates client state); the caller emits the log line after unlocking. +func (client *restUIClientLimit) shouldLogRejection(reason string, now time.Time) bool { + shouldLog := reason != client.lastRejectKind || now.Sub(client.lastRejectLog) >= time.Minute + if reason == restUIRejectIPBlocked && (client.blockRejected == 1 || client.blockRejected%1000 == 0) { + shouldLog = true + } + if shouldLog { + client.lastRejectKind = reason + client.lastRejectLog = now + } + return shouldLog +} + +func writeRestUIRateLimitResponse(w http.ResponseWriter, retryAfter time.Duration) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Content-Security-Policy", getContentSecurityPolicy()) + if retryAfter > 0 { + seconds := int64(math.Ceil(retryAfter.Seconds())) + if seconds < 1 { + seconds = 1 + } + w.Header().Set("Retry-After", strconv.FormatInt(seconds, 10)) + } + w.WriteHeader(http.StatusTooManyRequests) + if _, err := w.Write([]byte("{\"error\":\"rate limit exceeded\"}\n")); err != nil { + glog.Warning("write REST/UI rate-limit response: ", err) + } +} diff --git a/server/rest_rate_limiter_test.go b/server/rest_rate_limiter_test.go new file mode 100644 index 0000000000..026ec3d6c1 --- /dev/null +++ b/server/rest_rate_limiter_test.go @@ -0,0 +1,695 @@ +//go:build unittest + +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "net/netip" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func newTestRestUIRateLimiter() *restUIRateLimiter { + return &restUIRateLimiter{ + clients: make(map[string]*restUIClientLimit), + rateLimit: 0, + rateWindow: time.Minute, + burst: 1, + maxConcurrent: 0, + stateTTL: defaultRestUIStateTTL, + } +} + +type trackingBody struct { + read bool +} + +func (b *trackingBody) Read(_ []byte) (int, error) { + b.read = true + return 0, io.EOF +} + +func (b *trackingBody) Close() error { + return nil +} + +func TestRestUIRateLimiterRejectsWith429BeforeHandlerWork(t *testing.T) { + limiter := newTestRestUIRateLimiter() + limiter.rateLimit = 1 + limiter.burst = 1 + var handlerCalls int + handler := limiter.wrapPublic(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalls++ + w.WriteHeader(http.StatusNoContent) + }), "/") + + req := httptest.NewRequest(http.MethodGet, "http://example.com/api/v2/status", nil) + req.RemoteAddr = "192.0.2.1:12345" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("first request status = %d, want %d", rec.Code, http.StatusNoContent) + } + + body := &trackingBody{} + req = httptest.NewRequest(http.MethodPost, "http://example.com/api/v2/sendtx/", body) + req.RemoteAddr = "192.0.2.1:12345" + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("second request status = %d, want %d", rec.Code, http.StatusTooManyRequests) + } + if got := rec.Header().Get("Content-Type"); got != "application/json; charset=utf-8" { + t.Fatalf("Content-Type = %q", got) + } + if got := rec.Header().Get("Retry-After"); got == "" { + t.Fatal("Retry-After header missing") + } + if got := strings.TrimSpace(rec.Body.String()); got != `{"error":"rate limit exceeded"}` { + t.Fatalf("body = %q", got) + } + if handlerCalls != 1 { + t.Fatalf("handler calls = %d, want 1", handlerCalls) + } + if body.read { + t.Fatal("request body was read before rate-limit rejection") + } +} + +func TestRestUIRateLimiterConcurrentRequests(t *testing.T) { + limiter := newTestRestUIRateLimiter() + limiter.maxConcurrent = 1 + started := make(chan struct{}) + release := make(chan struct{}) + done := make(chan struct{}) + var startedOnce sync.Once + + handler := limiter.wrapPublic(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + startedOnce.Do(func() { close(started) }) + <-release + w.WriteHeader(http.StatusNoContent) + }), "/") + + req := httptest.NewRequest(http.MethodGet, "http://example.com/api/v2/status", nil) + req.RemoteAddr = "192.0.2.2:12345" + go func(req *http.Request) { + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Errorf("first request status = %d, want %d", rec.Code, http.StatusNoContent) + } + close(done) + }(req) + <-started + + req = httptest.NewRequest(http.MethodGet, "http://example.com/api/v2/status", nil) + req.RemoteAddr = "192.0.2.2:12345" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("concurrent request status = %d, want %d", rec.Code, http.StatusTooManyRequests) + } + + close(release) + <-done + + req = httptest.NewRequest(http.MethodGet, "http://example.com/api/v2/status", nil) + req.RemoteAddr = "192.0.2.2:12345" + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("request after release status = %d, want %d", rec.Code, http.StatusNoContent) + } +} + +func TestRestUIRateLimiterRouteScope(t *testing.T) { + limiter := newTestRestUIRateLimiter() + limiter.rateLimit = 1 + limiter.burst = 1 + var handlerCalls int + handler := limiter.wrapPublic(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalls++ + w.WriteHeader(http.StatusNoContent) + }), "/bb/") + + // Static assets, api-docs, the OpenAPI spec, and the WebSocket endpoint bypass + // the limiter and are served unconditionally. + for _, path := range []string{ + "/bb/static/app.js", + "/bb/websocket", + "/bb/favicon.ico", + "/bb/api-docs", + "/bb/openapi.yaml", + "/bb/test-websocket.html", + } { + req := httptest.NewRequest(http.MethodGet, "http://example.com"+path, nil) + req.RemoteAddr = "192.0.2.3:12345" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("%s status = %d, want %d", path, rec.Code, http.StatusNoContent) + } + } + + // One dynamic route consumes the single burst token; the next dynamic route is + // rejected, regardless of whether it is the API or an explorer UI page. + // deny-all-except-static limits every non-excluded route. + for i, path := range []string{"/bb/address/abc", "/bb/api/v2/status"} { + req := httptest.NewRequest(http.MethodGet, "http://example.com"+path, nil) + req.RemoteAddr = "192.0.2.3:12345" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + wantCode := http.StatusNoContent + if i > 0 { + wantCode = http.StatusTooManyRequests + } + if rec.Code != wantCode { + t.Fatalf("%s status = %d, want %d", path, rec.Code, wantCode) + } + } + // 6 bypass requests + 1 accepted dynamic request reached the handler. + if handlerCalls != 7 { + t.Fatalf("handler calls = %d, want 7", handlerCalls) + } +} + +func TestClientIPConfigEnvFallback(t *testing.T) { + t.Run("shared trusted proxies override legacy websocket fallback", func(t *testing.T) { + prefix := "CLIENTIPTESTA" + t.Setenv(prefix+"_TRUSTED_PROXIES", "203.0.113.0/24") + t.Setenv(prefix+"_WS_TRUSTED_PROXIES", "198.51.100.0/24") + cfg, err := readClientIPConfig(prefix) + if err != nil { + t.Fatal(err) + } + if cfg.trustedEnvName != prefix+"_TRUSTED_PROXIES" { + t.Fatalf("trusted env = %q", cfg.trustedEnvName) + } + if !prefixesContain(cfg.trustedProxies, "203.0.113.0/24") || prefixesContain(cfg.trustedProxies, "198.51.100.0/24") { + t.Fatalf("trusted proxies = %v", cfg.trustedProxies) + } + }) + + t.Run("unset shared trusted proxies fall back to legacy websocket", func(t *testing.T) { + prefix := "CLIENTIPTESTB" + t.Setenv(prefix+"_WS_TRUSTED_PROXIES", "198.51.100.0/24") + cfg, err := readClientIPConfig(prefix) + if err != nil { + t.Fatal(err) + } + if cfg.trustedEnvName != prefix+"_WS_TRUSTED_PROXIES" { + t.Fatalf("trusted env = %q", cfg.trustedEnvName) + } + if !prefixesContain(cfg.trustedProxies, "198.51.100.0/24") { + t.Fatalf("trusted proxies = %v", cfg.trustedProxies) + } + }) + + t.Run("explicit shared trusted proxies do not fall back", func(t *testing.T) { + prefix := "CLIENTIPTESTC" + t.Setenv(prefix+"_TRUSTED_PROXIES", "") + t.Setenv(prefix+"_WS_TRUSTED_PROXIES", "198.51.100.0/24") + cfg, err := readClientIPConfig(prefix) + if err != nil { + t.Fatal(err) + } + if cfg.trustedEnvName != prefix+"_TRUSTED_PROXIES" { + t.Fatalf("trusted env = %q", cfg.trustedEnvName) + } + if len(cfg.trustedProxies) != 0 { + t.Fatalf("trusted proxies = %v, want none", cfg.trustedProxies) + } + }) + + t.Run("shared Cloudflare CIDRs override legacy websocket fallback", func(t *testing.T) { + prefix := "CLIENTIPTESTD" + t.Setenv(prefix+"_CLOUDFLARE_IPS", "203.0.113.0/24") + t.Setenv(prefix+"_WS_CLOUDFLARE_IPS", "198.51.100.0/24") + cfg, err := readClientIPConfig(prefix) + if err != nil { + t.Fatal(err) + } + if cfg.cloudflareEnvName != prefix+"_CLOUDFLARE_IPS" { + t.Fatalf("Cloudflare env = %q", cfg.cloudflareEnvName) + } + if !prefixesContain(cfg.cloudflarePrefixes, "203.0.113.0/24") || prefixesContain(cfg.cloudflarePrefixes, "198.51.100.0/24") { + t.Fatalf("Cloudflare CIDRs = %v", cfg.cloudflarePrefixes) + } + }) + + t.Run("unset shared Cloudflare CIDRs fall back to legacy websocket", func(t *testing.T) { + prefix := "CLIENTIPTESTE" + t.Setenv(prefix+"_WS_CLOUDFLARE_IPS", "203.0.113.0/24") + cfg, err := readClientIPConfig(prefix) + if err != nil { + t.Fatal(err) + } + if cfg.cloudflareEnvName != prefix+"_WS_CLOUDFLARE_IPS" { + t.Fatalf("Cloudflare env = %q", cfg.cloudflareEnvName) + } + if !prefixesContain(cfg.cloudflarePrefixes, "203.0.113.0/24") { + t.Fatalf("Cloudflare CIDRs = %v", cfg.cloudflarePrefixes) + } + }) + + t.Run("explicit shared Cloudflare off does not fall back", func(t *testing.T) { + prefix := "CLIENTIPTESTF" + t.Setenv(prefix+"_CLOUDFLARE_IPS", "off") + t.Setenv(prefix+"_WS_CLOUDFLARE_IPS", "203.0.113.0/24") + cfg, err := readClientIPConfig(prefix) + if err != nil { + t.Fatal(err) + } + if cfg.cloudflareEnvName != prefix+"_CLOUDFLARE_IPS" { + t.Fatalf("Cloudflare env = %q", cfg.cloudflareEnvName) + } + if len(cfg.cloudflarePrefixes) != 0 { + t.Fatalf("Cloudflare CIDRs = %v, want none", cfg.cloudflarePrefixes) + } + }) + + t.Run("Pseudo-IPv4 defaults to off and parses true", func(t *testing.T) { + prefix := "CLIENTIPTESTG" + cfg, err := readClientIPConfig(prefix) + if err != nil { + t.Fatal(err) + } + if cfg.trustPseudoIPv6 { + t.Fatalf("trustPseudoIPv6 = true by default, want false") + } + t.Setenv(prefix+"_CLOUDFLARE_PSEUDO_IPV4", "true") + cfg, err = readClientIPConfig(prefix) + if err != nil { + t.Fatal(err) + } + if !cfg.trustPseudoIPv6 { + t.Fatalf("trustPseudoIPv6 = false, want true") + } + }) + + t.Run("Pseudo-IPv4 rejects an unparseable value", func(t *testing.T) { + prefix := "CLIENTIPTESTH" + t.Setenv(prefix+"_CLOUDFLARE_PSEUDO_IPV4", "maybe") + if _, err := readClientIPConfig(prefix); err == nil { + t.Fatal("expected error for invalid CLOUDFLARE_PSEUDO_IPV4, got nil") + } + }) +} + +func TestReadRestUILimiterConfig(t *testing.T) { + t.Run("defaults to the lowered values when unset", func(t *testing.T) { + cfg, err := readRestUILimiterConfig("RESTUITESTDEFAULTS") + if err != nil { + t.Fatal(err) + } + // Lock the lowered defaults so a regression in either the constants or the + // env parsing is caught here, not only in the dashboard/docs. + if cfg.rateLimit != 180 { + t.Fatalf("default rateLimit = %d, want 180", cfg.rateLimit) + } + if cfg.burst != 40 { + t.Fatalf("default burst = %d, want 40", cfg.burst) + } + if cfg.maxConcurrent != 12 { + t.Fatalf("default maxConcurrent = %d, want 12", cfg.maxConcurrent) + } + if cfg.rateWindow != time.Minute { + t.Fatalf("default rateWindow = %s, want 1m", cfg.rateWindow) + } + if cfg.stateTTL != defaultRestUIStateTTL { + t.Fatalf("default stateTTL = %s, want %s", cfg.stateTTL, defaultRestUIStateTTL) + } + if cfg.blockDuration != defaultRestUIBlockDuration { + t.Fatalf("default blockDuration = %s, want %s", cfg.blockDuration, time.Duration(defaultRestUIBlockDuration)) + } + }) + + t.Run("REST_UI_* env vars override the defaults", func(t *testing.T) { + prefix := "RESTUITESTOVERRIDE" + t.Setenv(prefix+"_REST_UI_RATE_LIMIT", "999") + t.Setenv(prefix+"_REST_UI_RATE_WINDOW", "30s") + t.Setenv(prefix+"_REST_UI_BURST", "55") + t.Setenv(prefix+"_REST_UI_MAX_CONCURRENT", "7") + t.Setenv(prefix+"_REST_UI_STATE_TTL", "5m") + t.Setenv(prefix+"_REST_UI_BLOCK_DURATION", "1h") + cfg, err := readRestUILimiterConfig(prefix) + if err != nil { + t.Fatal(err) + } + if cfg.rateLimit != 999 { + t.Fatalf("rateLimit = %d, want 999", cfg.rateLimit) + } + if cfg.rateWindow != 30*time.Second { + t.Fatalf("rateWindow = %s, want 30s", cfg.rateWindow) + } + if cfg.burst != 55 { + t.Fatalf("burst = %d, want 55", cfg.burst) + } + if cfg.maxConcurrent != 7 { + t.Fatalf("maxConcurrent = %d, want 7", cfg.maxConcurrent) + } + if cfg.stateTTL != 5*time.Minute { + t.Fatalf("stateTTL = %s, want 5m", cfg.stateTTL) + } + if cfg.blockDuration != time.Hour { + t.Fatalf("blockDuration = %s, want 1h", cfg.blockDuration) + } + }) + + t.Run("zero burst with request-rate limiting enabled is rejected", func(t *testing.T) { + prefix := "RESTUITESTBADBURST" + t.Setenv(prefix+"_REST_UI_BURST", "0") + if _, err := readRestUILimiterConfig(prefix); err == nil { + t.Fatal("expected error for zero burst while rate limiting is enabled, got nil") + } + }) +} + +func TestRestUIRateLimiterTemporaryBlock(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + limiter := newTestRestUIRateLimiter() + limiter.rateLimit = 1 + limiter.burst = 1 + limiter.blockDuration = time.Minute + + if decision := limiter.accept("192.0.2.10", "192.0.2.10", true, now); !decision.accepted { + t.Fatalf("first decision = %+v, want accepted", decision) + } + limiter.release("192.0.2.10", now) + // breaches must be separate episodes (>= restUIBreachMinSpacing apart) to + // count toward the block threshold + var last time.Time + for i := 0; i < restUIBreachBlockThreshold; i++ { + last = now.Add(time.Duration(i) * restUIBreachMinSpacing) + decision := limiter.accept("192.0.2.10", "192.0.2.10", true, last) + if decision.accepted || decision.reason != restUIRejectRequestRate { + t.Fatalf("breach %d decision = %+v, want request-rate rejection", i, decision) + } + } + decision := limiter.accept("192.0.2.10", "192.0.2.10", true, last.Add(time.Second)) + if decision.accepted || decision.reason != restUIRejectIPBlocked { + t.Fatalf("blocked decision = %+v, want ip_blocked", decision) + } + + // a burst of same-instant rejections is one breach episode and must not + // trip the temporary block + limiter = newTestRestUIRateLimiter() + limiter.rateLimit = 1 + limiter.burst = 1 + limiter.blockDuration = time.Minute + if decision := limiter.accept("192.0.2.11", "192.0.2.11", true, now); !decision.accepted { + t.Fatalf("first burst decision = %+v, want accepted", decision) + } + limiter.release("192.0.2.11", now) + for i := 0; i < 3*restUIBreachBlockThreshold; i++ { + decision := limiter.accept("192.0.2.11", "192.0.2.11", true, now) + if decision.accepted || decision.reason != restUIRejectRequestRate { + t.Fatalf("burst rejection %d decision = %+v, want request-rate rejection", i, decision) + } + } + if limiter.clients["192.0.2.11"].blockedUntil.After(now) { + t.Fatal("same-instant rejection burst tripped the temporary block") + } + + limiter = newTestRestUIRateLimiter() + limiter.rateLimit = 1 + limiter.burst = 1 + limiter.blockDuration = time.Minute + if decision := limiter.accept("127.0.0.1", "127.0.0.1", false, now); !decision.accepted { + t.Fatalf("first unblockable decision = %+v, want accepted", decision) + } + limiter.release("127.0.0.1", now) + for i := 0; i < restUIBreachBlockThreshold+1; i++ { + decision = limiter.accept("127.0.0.1", "127.0.0.1", false, now.Add(time.Duration(i)*restUIBreachMinSpacing)) + if decision.accepted || decision.reason != restUIRejectRequestRate { + t.Fatalf("unblockable breach %d decision = %+v, want request-rate rejection", i, decision) + } + } + if limiter.clients["127.0.0.1"].blockedUntil.After(now) { + t.Fatal("unblockable key was temporarily blocked") + } +} + +func TestRestUIRateLimiterIPv6BlockIsPer128(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + limiter := newTestRestUIRateLimiter() + limiter.rateLimit = 1 + limiter.burst = 1 + limiter.blockDuration = time.Minute + + // Two distinct /128s sharing one /64. The rate limiter aggregates to /64 + // (key64); the block must accrue per /128 (b1, b2). + key64 := rateLimitKey("2001:db8:1:2::1") + b1 := blockKey("2001:db8:1:2::1") + b2 := blockKey("2001:db8:1:2::2") + if key64 != rateLimitKey("2001:db8:1:2::2") { + t.Fatalf("test setup: addresses are not in the same /64 (%q vs %q)", key64, rateLimitKey("2001:db8:1:2::2")) + } + + // Consume the single shared token, then drive separate breach episodes that + // are all attributed to b1. + if d := limiter.accept(key64, b1, true, now); !d.accepted { + t.Fatalf("first decision = %+v, want accepted", d) + } + limiter.release(key64, now) + var last time.Time + for i := 0; i < restUIBreachBlockThreshold; i++ { + last = now.Add(time.Duration(i) * restUIBreachMinSpacing) + if d := limiter.accept(key64, b1, true, last); d.accepted || d.reason != restUIRejectRequestRate { + t.Fatalf("breach %d decision = %+v, want request-rate rejection", i, d) + } + } + after := last.Add(time.Second) + if d := limiter.accept(key64, b1, true, after); d.accepted || d.reason != restUIRejectIPBlocked { + t.Fatalf("b1 decision = %+v, want ip_blocked", d) + } + + // A different /128 in the same /64 must NOT inherit the block: it is still + // subject to the shared /64 rate limit (request_rate) but never ip_blocked. + if d := limiter.accept(key64, b2, true, after); d.reason == restUIRejectIPBlocked { + t.Fatalf("b2 (same /64) wrongly ip_blocked: %+v", d) + } + if c := limiter.clients[b2]; c != nil && c.blockedUntil.After(after) { + t.Fatal("b2 must not be blocked when only b1 breached") + } +} + +func TestRestUIRateLimiterCleanup(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + limiter := newTestRestUIRateLimiter() + limiter.clients["idle"] = &restUIClientLimit{lastSeen: now.Add(-limiter.stateTTL - time.Second)} + limiter.clients["active"] = &restUIClientLimit{active: 1, lastSeen: now.Add(-limiter.stateTTL - time.Second)} + limiter.clients["blocked"] = &restUIClientLimit{blockedUntil: now.Add(time.Minute), lastSeen: now.Add(-limiter.stateTTL - time.Second)} + + limiter.sweep(now) + if _, ok := limiter.clients["idle"]; ok { + t.Fatal("idle client was not removed") + } + if _, ok := limiter.clients["active"]; !ok { + t.Fatal("active client was removed") + } + if _, ok := limiter.clients["blocked"]; !ok { + t.Fatal("blocked client was removed") + } +} + +func TestRestUIRouteMatching(t *testing.T) { + tests := []struct { + path string + basePath string + want bool + }{ + // Dynamic routes (API + explorer UI) are limited under the base path. + {"/", "/", true}, + {"/api", "/", true}, + {"/api/v2/status", "/", true}, + {"/address/abc", "/", true}, + {"/xpub/x", "/", true}, + {"/apix", "/", true}, + {"/bb/", "/bb/", true}, + {"/bb/api/v2/status", "/bb/", true}, + {"/bb/address/abc", "/bb/", true}, + // Static assets, docs, the OpenAPI spec, and WebSocket are exempt. + {"/static/app.js", "/", false}, + {"/favicon.ico", "/", false}, + {"/api-docs", "/", false}, + {"/api-docs/", "/", false}, + // A path that only shares the "api-docs" prefix is not the docs route and + // must stay limited (it falls through to the explorer index handler). + {"/api-docsx", "/", true}, + {"/openapi.yaml", "/", false}, + {"/websocket", "/", false}, + {"/test-websocket.html", "/", false}, + {"/bb/static/app.js", "/bb/", false}, + {"/bb/websocket", "/bb/", false}, + // A "-public=:port/path" binding without a trailing slash yields basePath + // "/bb" (splitBinding keeps the path verbatim). Dynamic routes registered by + // raw concatenation then live at "/bbapi/" etc., while static assets are + // registered via publicPath at "/bb/...". Both shapes must still classify + // correctly: dynamic limited, static/docs exempt. + {"/bb", "/bb", true}, + {"/bbapi/v2/status", "/bb", true}, + {"/bb/address/abc", "/bb", true}, + {"/bb/static/app.js", "/bb", false}, + {"/bb/favicon.ico", "/bb", false}, + {"/bb/api-docs", "/bb", false}, + {"/bb/openapi.yaml", "/bb", false}, + // Requests outside the configured base path are not limited here. + {"/other/path", "/bb/", false}, + } + for _, tt := range tests { + if got := isRateLimitedRoute(tt.path, tt.basePath); got != tt.want { + t.Fatalf("isRateLimitedRoute(%q, %q) = %v, want %v", tt.path, tt.basePath, got, tt.want) + } + } +} + +func TestRestUIRateLimiterStats(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + limiter := newTestRestUIRateLimiter() + limiter.clients["a"] = &restUIClientLimit{active: 2} + limiter.clients["b"] = &restUIClientLimit{active: 1, blockedUntil: now.Add(time.Minute)} + limiter.clients["c"] = &restUIClientLimit{} + + activeIPs, maxActive, blockedIPs := limiter.stats(now) + if activeIPs != 2 || maxActive != 2 || blockedIPs != 1 { + t.Fatalf("stats = %d, %d, %d; want 2, 2, 1", activeIPs, maxActive, blockedIPs) + } +} + +func prefixesContain(prefixes []netip.Prefix, cidr string) bool { + want := netip.MustParsePrefix(cidr) + for _, p := range prefixes { + if p == want { + return true + } + } + return false +} + +func TestRestUIRateLimiterReleaseIsPerKey(t *testing.T) { + limiter := newTestRestUIRateLimiter() + limiter.maxConcurrent = 2 + now := time.Unix(1_700_000_000, 0) + if decision := limiter.accept("192.0.2.20", "192.0.2.20", true, now); !decision.accepted { + t.Fatalf("accept = %+v", decision) + } + if decision := limiter.accept("192.0.2.20", "192.0.2.20", true, now); !decision.accepted { + t.Fatalf("second accept = %+v", decision) + } + limiter.release("192.0.2.20", now) + if got := limiter.clients["192.0.2.20"].active; got != 1 { + t.Fatalf("active = %d, want 1", got) + } + limiter.release("192.0.2.20", now) + if got := limiter.clients["192.0.2.20"].active; got != 0 { + t.Fatalf("active after second release = %d, want 0", got) + } +} + +func TestRestUIRateLimiterBypassDoesNotConsumeToken(t *testing.T) { + limiter := newTestRestUIRateLimiter() + limiter.rateLimit = 1 + limiter.burst = 1 + var calls atomic.Int32 + handler := limiter.wrapPublic(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusNoContent) + }), "/") + + for i := 0; i < 5; i++ { + req := httptest.NewRequest(http.MethodGet, "http://example.com/static/app.js", nil) + req.RemoteAddr = "192.0.2.30:12345" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("bypass request %d status = %d, want %d", i, rec.Code, http.StatusNoContent) + } + } + + req := httptest.NewRequest(http.MethodGet, "http://example.com/api/v2/status", nil) + req.RemoteAddr = "192.0.2.30:12345" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("API request after bypass status = %d, want %d", rec.Code, http.StatusNoContent) + } + if calls.Load() != 6 { + t.Fatalf("handler calls = %d, want 6", calls.Load()) + } +} + +func TestRestUIRateLimiterLocalPeerBypass(t *testing.T) { + limiter := newTestRestUIRateLimiter() + limiter.rateLimit = 1 + limiter.burst = 1 + handler := limiter.wrapPublic(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), "/") + + // A loopback/private peer without any attribution header is the operator's + // own tooling or a proxy that forwards no client IP; limiting that key would + // throttle a whole deployment as one client, so it is exempt. + for _, remote := range []string{"127.0.0.1:40000", "[::1]:40000", "10.1.2.3:40000"} { + for i := 0; i < 5; i++ { + req := httptest.NewRequest(http.MethodGet, "http://example.com/api/v2/status", nil) + req.RemoteAddr = remote + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("local request %d from %s status = %d, want %d", i, remote, rec.Code, http.StatusNoContent) + } + } + } + if len(limiter.clients) != 0 { + t.Fatalf("local bypass created limiter state for %d keys", len(limiter.clients)) + } + + // The same loopback peer forwarding a client IP via X-Real-Ip is limited on + // that client key. + for i, want := range []int{http.StatusNoContent, http.StatusTooManyRequests} { + req := httptest.NewRequest(http.MethodGet, "http://example.com/api/v2/status", nil) + req.RemoteAddr = "127.0.0.1:40000" + req.Header.Set("X-Real-Ip", "192.0.2.77") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != want { + t.Fatalf("attributed request %d status = %d, want %d", i, rec.Code, want) + } + } +} + +func TestRestUIRateLimiterTrackingCap(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + limiter := newTestRestUIRateLimiter() + limiter.rateLimit = 1 + limiter.burst = 1 + for i := 0; i < restUIMaxTrackedClients; i++ { + limiter.clients[strconv.Itoa(i)] = &restUIClientLimit{lastSeen: now} + } + + decision := limiter.accept("192.0.2.99", "192.0.2.99", true, now) + if !decision.accepted || !decision.untracked { + t.Fatalf("decision at cap = %+v, want accepted and untracked", decision) + } + if _, ok := limiter.clients["192.0.2.99"]; ok { + t.Fatal("untracked accept created limiter state") + } + // release of an untracked key is a no-op + limiter.release("192.0.2.99", now) + + // already-tracked keys stay limited at the cap + if decision := limiter.accept("0", "0", true, now); !decision.accepted { + t.Fatalf("tracked accept = %+v, want accepted", decision) + } + if decision := limiter.accept("0", "0", true, now); decision.accepted { + t.Fatalf("tracked second accept = %+v, want rejected", decision) + } +} 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..2bdd12b9c8 --- /dev/null +++ b/server/tron_template.go @@ -0,0 +1,115 @@ +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 + StakingInfoData *tronStakingInfoTemplateData `json:"-"` +} + +type tronStakingInfoTemplateData struct { + bchain.TronStakingInfo + StakedBalanceValue *api.Amount `json:"-"` + StakedBalanceEnergyValue *api.Amount `json:"-"` + StakedBalanceBandwidthValue *api.Amount `json:"-"` + UnclaimedRewardValue *api.Amount `json:"-"` + DelegatedBalanceEnergyValue *api.Amount `json:"-"` + DelegatedBalanceBandwidthValue *api.Amount `json:"-"` + UnstakingBatchesData []tronUnstakingBatchTemplateData `json:"-"` +} + +type tronUnstakingBatchTemplateData struct { + bchain.TronUnstakingBatch + AmountValue *api.Amount `json:"-"` +} + +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, + } + if extra.StakingInfo != nil { + unstakingBatches := make([]tronUnstakingBatchTemplateData, len(extra.StakingInfo.UnstakingBatches)) + for i := range extra.StakingInfo.UnstakingBatches { + batch := extra.StakingInfo.UnstakingBatches[i] + unstakingBatches[i] = tronUnstakingBatchTemplateData{ + TronUnstakingBatch: batch, + AmountValue: parseTronSunAmount(batch.Amount), + } + } + rv.StakingInfoData = &tronStakingInfoTemplateData{ + TronStakingInfo: *extra.StakingInfo, + StakedBalanceValue: parseTronSunAmount(extra.StakingInfo.StakedBalance), + StakedBalanceEnergyValue: parseTronSunAmount(extra.StakingInfo.StakedBalanceEnergy), + StakedBalanceBandwidthValue: parseTronSunAmount(extra.StakingInfo.StakedBalanceBandwidth), + UnclaimedRewardValue: parseTronSunAmount(extra.StakingInfo.UnclaimedReward), + DelegatedBalanceEnergyValue: parseTronSunAmount(extra.StakingInfo.DelegatedBalanceEnergy), + DelegatedBalanceBandwidthValue: parseTronSunAmount(extra.StakingInfo.DelegatedBalanceBandwidth), + UnstakingBatchesData: unstakingBatches, + } + } + 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..2d7010f4d8 --- /dev/null +++ b/server/tron_template_test.go @@ -0,0 +1,153 @@ +//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","note":"hello memo","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.Note != "hello memo" { + t.Fatalf("unexpected note %q", got.Note) + } + 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,"totalEnergyLimit":180000000000,"totalEnergyWeight":2363311832,"totalBandwidthLimit":43200000000,"totalBandwidthWeight":68292467803,"stakingInfo":{"stakedBalance":"7000000","stakedBalanceEnergy":"5000000","stakedBalanceBandwidth":"2000000","unstakingBatches":[{"amount":"1112757","expireTime":1777018452}],"totalVotingPower":"10","availableVotingPower":"7","votes":[{"address":"TA","voteCount":"2"}],"unclaimedReward":"42767","delegatedBalanceEnergy":"3210000","delegatedBalanceBandwidth":"654000"}}`), + }, + } + 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) + } + if got.TotalEnergyLimit != 180000000000 || got.TotalEnergyWeight != 2363311832 || got.TotalBandwidthLimit != 43200000000 || got.TotalBandwidthWeight != 68292467803 { + t.Fatalf("unexpected total resource values %+v", got) + } + if got.StakingInfoData == nil { + t.Fatal("expected staking info data") + } + if got.StakingInfoData.StakedBalanceValue == nil || got.StakingInfoData.StakedBalanceValue.DecimalString(6) != "7" { + t.Fatalf("unexpected staked balance %+v", got.StakingInfoData.StakedBalanceValue) + } + if got.StakingInfoData.StakedBalanceEnergyValue == nil || got.StakingInfoData.StakedBalanceEnergyValue.DecimalString(6) != "5" { + t.Fatalf("unexpected staked energy balance %+v", got.StakingInfoData.StakedBalanceEnergyValue) + } + if got.StakingInfoData.StakedBalanceBandwidthValue == nil || got.StakingInfoData.StakedBalanceBandwidthValue.DecimalString(6) != "2" { + t.Fatalf("unexpected staked bandwidth balance %+v", got.StakingInfoData.StakedBalanceBandwidthValue) + } + if len(got.StakingInfoData.UnstakingBatchesData) != 1 { + t.Fatalf("unexpected unstaking batches %+v", got.StakingInfoData.UnstakingBatchesData) + } + if got.StakingInfoData.UnstakingBatchesData[0].AmountValue == nil || got.StakingInfoData.UnstakingBatchesData[0].AmountValue.DecimalString(6) != "1.112757" { + t.Fatalf("unexpected unstaking batch amount %+v", got.StakingInfoData.UnstakingBatchesData[0].AmountValue) + } + if len(got.StakingInfoData.Votes) != 1 || got.StakingInfoData.Votes[0].Address != "TA" || got.StakingInfoData.Votes[0].VoteCount != "2" { + t.Fatalf("unexpected votes %+v", got.StakingInfoData.Votes) + } + if got.StakingInfoData.UnclaimedRewardValue == nil || got.StakingInfoData.UnclaimedRewardValue.DecimalString(6) != "0.042767" { + t.Fatalf("unexpected unclaimed reward %+v", got.StakingInfoData.UnclaimedRewardValue) + } + if got.StakingInfoData.DelegatedBalanceEnergyValue == nil || got.StakingInfoData.DelegatedBalanceEnergyValue.DecimalString(6) != "3.21" { + t.Fatalf("unexpected delegated energy balance %+v", got.StakingInfoData.DelegatedBalanceEnergyValue) + } + if got.StakingInfoData.DelegatedBalanceBandwidthValue == nil || got.StakingInfoData.DelegatedBalanceBandwidthValue.DecimalString(6) != "0.654" { + t.Fatalf("unexpected delegated bandwidth balance %+v", got.StakingInfoData.DelegatedBalanceBandwidthValue) + } + }) + + 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 15ebcdd2d4..04d75e2319 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -1,9 +1,13 @@ package server import ( + "context" "encoding/json" "math/big" "net/http" + "net/netip" + "net/url" + "os" "runtime/debug" "strconv" "strings" @@ -14,15 +18,24 @@ import ( "github.com/golang/glog" "github.com/gorilla/websocket" "github.com/juju/errors" - "github.com/syscoin/blockbook/api" - "github.com/syscoin/blockbook/bchain" - "github.com/syscoin/blockbook/common" - "github.com/syscoin/blockbook/db" + "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" ) 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 maxWebsocketActiveRequests = 2048 +const maxWebsocketEstimateFeeBlocks = 32 +const maxWebsocketSubscribeAddresses = 1000 +const maxWebsocketSubscribeAddressesWithNewBlockTxs = 100 +const websocketLogPreviewBytes = 256 // allRates is a special "currency" parameter that means all available currencies const allFiatRates = "!ALL!" @@ -34,31 +47,40 @@ 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"` +// websocketChannel is a single client connection. ipKey is the per-IP rate-limit +// key (IPv6 aggregated to /64); blockKey is the IP-blocklist key (full /128) kept +// narrower so a hard block does not take out a whole shared /64; blockable records +// whether blockKey is safe to add to the blocklist; messageRate is the per-connection +// message counter (nil when disabled). All are touched only by ServeHTTP/inputLoop. +type websocketChannel struct { + id uint64 + requests uint64 // total requests received on this connection, accessed atomically + conn *websocket.Conn + out chan *WsRes + pendingRequests chan struct{} + ip string + ipKey string + blockKey string + blockable bool + messageRate *connMessageRate + 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 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 +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 type WebsocketServer struct { - socket *websocket.Conn upgrader *websocket.Upgrader db *db.RocksDB txCache *db.TxCache @@ -74,15 +96,50 @@ 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 - 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 + // cloudflarePrefixes gates trust of the CF-Connecting-* headers: when + // non-empty, those headers are honored only when the TCP peer is inside one + // of these ranges (or a loopback/private proxy). Empty disables verification + // and falls back to the legacy "trust CF headers from any peer" behavior. + cloudflarePrefixes []netip.Prefix + // trustPseudoIPv6 honors the (otherwise client-spoofable) CF-Connecting-IPv6 + // header; only safe with Cloudflare "Pseudo IPv4: Overwrite Headers" on. + trustPseudoIPv6 bool + // messageRateLimit / messageRateWindow bound how many messages a single + // connection may send in a trailing window before it is closed; 0 disables. + // ipBlockDuration is how long an offending client key is blocked from + // opening new connections; 0 disables blocking (the connection is still + // closed on breach). + messageRateLimit int + messageRateWindow time.Duration + ipBlockDuration time.Duration + // ipBlockEnabled gates the whole IP blocklist: true only when the rate limit can + // produce breaches (messageRateLimit > 0) and blocking is configured + // (ipBlockDuration > 0). When false, all block checks/sweeps are skipped. + ipBlockEnabled bool + 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{} + activeRequests int + requestWg sync.WaitGroup } // 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 } @@ -91,11 +148,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, @@ -106,45 +158,213 @@ 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, + WriteBufferPool: &sync.Pool{}, + 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) + } + clientIPCfg, err := readClientIPConfig(is.GetNetwork()) + if err != nil { + return nil, err } + s.trustedProxyPrefixes = clientIPCfg.trustedProxies + if len(clientIPCfg.trustedProxies) > 0 { + glog.Info("Trusted proxy CIDRs (", clientIPCfg.trustedEnvName, "): ", clientIPCfg.trustedProxies) + } + s.cloudflarePrefixes = clientIPCfg.cloudflarePrefixes + if len(clientIPCfg.cloudflarePrefixes) > 0 { + glog.Info("Cloudflare peer verification enabled for CF-Connecting-* headers (", clientIPCfg.cloudflareEnvName, "; ", len(clientIPCfg.cloudflarePrefixes), " CIDRs)") + } else { + glog.Warning("Cloudflare peer verification disabled (", clientIPCfg.cloudflareEnvName, "=off); CF-Connecting-* headers are trusted from any peer") + } + s.trustPseudoIPv6 = clientIPCfg.trustPseudoIPv6 + if clientIPCfg.trustPseudoIPv6 { + glog.Info("Cloudflare Pseudo-IPv4 mode enabled (", clientIPCfg.pseudoIPv6EnvName, "); CF-Connecting-IPv6 is honored as the client IP (requires Cloudflare \"Pseudo IPv4: Overwrite Headers\")") + } + if err := s.configureMessageRateLimit(is.GetNetwork()); err != nil { + return nil, err + } + if s.metrics != nil { + s.metrics.WebsocketNewBlockTxsSubscriptions.Set(0) + s.metrics.WebsocketUniqueIPs.Set(0) + s.metrics.WebsocketMaxConnectionsPerIP.Set(0) + s.metrics.WebsocketBlockedIPs.Set(0) + } + go s.runWebsocketLimiterMaintenance(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 +} + +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 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, blockSafe, _ := resolveClientIP(r, s.trustedProxyPrefixes, s.cloudflarePrefixes, s.trustPseudoIPv6) + ipKey := rateLimitKey(ip) + // blockKey/blockable are computed only when the IP blocklist is enabled (skips the + // O(prefixes) isBlockableKey scan otherwise). blockKey keeps IPv6 at the full /128 + // so a block never takes out a shared /64 (ipKey still aggregates to /64). + bKey := "" + blockable := false + if s.ipBlockEnabled { + bKey = blockKey(ip) + blockable = blockSafe && isBlockableKey(ip, s.trustedProxyPrefixes, s.cloudflarePrefixes) + } + + // Reject keys that are on the temporary IP blocklist before doing any + // upgrade work. Checked ahead of the connection limiter so a blocked client + // cannot keep consuming attempt slots. + if s.ipBlockEnabled { + if blocked, rejected := s.is.IsWsIPBlocked(bKey, time.Now()); blocked { + if s.metrics != nil { + s.metrics.WebsocketBlockedConnections.Inc() + } + // A blocked client may hammer reconnects for the whole block + // duration; log the first rejection and then every 1000th instead of + // one line per attempt. + if rejected == 1 || rejected%1000 == 0 { + glog.Warning("Websocket connection rejected, ", ip, ", ip_blocked (attempt ", rejected, ")") + } + http.Error(w, "Too many websocket connections", http.StatusTooManyRequests) + return + } + } + + limited := false + if s.websocketLimiter != nil { + ok, reason := s.websocketLimiter.accept(ipKey, time.Now()) + if !ok { + if s.metrics != nil { + s.metrics.WebsocketConnectionRejections.With(common.Labels{"reason": reason}).Inc() + } + 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(ipKey, 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, + ipKey: ipKey, + blockKey: bKey, + blockable: blockable, + requestHeader: r.Header, + alive: true, + } + if s.messageRateLimit > 0 { + c.messageRate = newConnMessageRate(s.messageRateWindow) + // count ping/pong control frames too; gorilla handles them inside + // ReadMessage so they never reach inputLoop and would otherwise be a + // free flood channel + s.installControlFrameRateLimit(c) + } + if s.is.WsGetAccountInfoLimit > 0 { + c.getAddressInfoDescriptors = make(map[string]struct{}) + } + if !s.registerChannel(c) { + conn.Close() + if limited { + s.websocketLimiter.release(ipKey, time.Now()) + } + return } go s.inputLoop(c) go s.outputLoop(c) @@ -156,29 +376,122 @@ 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, string) { + s.shutdownMu.Lock() + defer s.shutdownMu.Unlock() + if s.shuttingDown { + return false, "server_shutdown" + } + if s.activeRequests >= maxWebsocketActiveRequests { + return false, "work_limit" + } + s.activeRequests++ + s.requestWg.Add(1) + return true, "" +} + +func (s *WebsocketServer) workDone() { + s.shutdownMu.Lock() + if s.activeRequests > 0 { + s.activeRequests-- + } + s.shutdownMu.Unlock() + 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 { @@ -186,6 +499,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() @@ -193,43 +509,85 @@ 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 + } + atomic.AddUint64(&c.requests, 1) + if c.messageRate != nil { + // Breach on the message that pushes the trailing-window count past + // the limit, so exactly messageRateLimit messages are allowed per + // window (matches the "sends more than" contract). + if count := c.messageRate.observe(time.Now()); count > s.messageRateLimit { + s.onMessageRateBreach(c, count) + 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 ok, reason := s.trackWork(); !ok { + c.releaseRequestSlot() + if reason == "work_limit" { + e := resultError{} + e.Error.Message = reason + c.DataOut(&WsRes{ + ID: req.ID, + Data: e, + }) + continue + } + s.closeChannel(c, reason) + 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) - return - case websocket.PongMessage: - // do nothing } + // ReadMessage returns only data frames; ping/pong/close control frames + // are consumed inside gorilla and dispatched to the connection's + // handlers (see installControlFrameRateLimit), surfacing here only as a + // read error. } } @@ -237,14 +595,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 } } @@ -260,50 +619,77 @@ func (s *WebsocketServer) onDisconnect(c *websocketChannel) { s.unsubscribeNewTransaction(c) s.unsubscribeAddresses(c) s.unsubscribeFiatRates(c) + if s.websocketLimiter != nil { + s.websocketLimiter.release(c.ipKey, time.Now()) + } + s.unregisterChannel(c) glog.Info("Client disconnected ", c.id, ", ", c.ip) + s.metrics.WebsocketConnectionRequests.Observe(float64(atomic.LoadUint64(&c.requests))) 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 { @@ -315,121 +701,153 @@ var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *webs if r.GroupBy <= 0 { r.GroupBy = 3600 } - rv, err = s.api.GetXpubBalanceHistory(r.Descriptor, r.From, r.To, r.Currencies, r.Gap, r.GroupBy) - if err != nil { - rv, err = s.api.GetBalanceHistory(r.Descriptor, r.From, r.To, r.Currencies, r.GroupBy) + rv, err = s.api.GetXpubBalanceHistory(r.Descriptor, r.From, r.To, r.Currencies, r.Gap, r.GroupBy, s.is.BalanceHistoryMaxTxsWS, api.BalanceHistoryTransportWS) + if apiErr, ok := err.(*api.APIError); ok && apiErr.Public { + // A public error from the xpub path (e.g. the range spans too many + // transactions) is definitive for a valid xpub; do not retry as an + // address, which would mask it with an address-parse error. + } else if err != nil { + rv, err = s.api.GetBalanceHistory(r.Descriptor, r.From, r.To, r.Currencies, r.GroupBy, s.is.BalanceHistoryMaxTxsWS, api.BalanceHistoryTransportWS) } } 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 *websocketReq) (rv interface{}, err error) { - r := struct { - Hex string `json:"hex"` - }{} + "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) + rv, err = s.sendTransaction(r) } return }, - "subscribeNewBlock": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { + + "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 + }, + "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.getBlockFiltersBatch(&r) + } + return + }, + "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) { - r := struct { - Currency string `json:"currency"` - }{} + "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 } - return s.subscribeFiatRates(c, strings.ToLower(r.Currency), req) + r.Currency = strings.ToLower(r.Currency) + + 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"` - }{} + "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) + 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"` - }{} + "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) + 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"` - }{} + "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.getFiatRatesTickersList(r.Timestamp) + rv, err = s.getAvailableVsCurrencies(r.Timestamp, r.Token) } return }, } -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) @@ -440,50 +858,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"` - 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 @@ -491,7 +900,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": @@ -517,36 +929,36 @@ func (s *WebsocketServer) getAccountInfo(req *accountInfoReq) (res *api.Address, tokensToReturn = api.TokensToReturnDerived } filter := api.AddressFilter{ - FromHeight: uint32(req.FromHeight), - ToHeight: uint32(req.ToHeight), - Contract: req.ContractFilter, - Vout: api.AddressFilterVoutOff, - TokensToReturn: tokensToReturn, - AssetsMask: bchain.AllMask, - } - if req.PageSize == 0 { - req.PageSize = txsOnPage + FromHeight: uint32(req.FromHeight), + ToHeight: uint32(req.ToHeight), + Contract: req.ContractFilter, + Vout: api.AddressFilterVoutOff, + TokensToReturn: tokensToReturn, + Protocols: req.Protocols, + WithConfirmedNonce: req.ConfirmedNonce, } - a, err := s.api.GetXpubAddress(req.Descriptor, req.Page, req.PageSize, opt, &filter, req.Gap) + 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) + return s.api.GetAddress(req.Descriptor, req.Page, req.PageSize, opt, &filter, strings.ToLower(req.SecondaryCurrency)) } return a, nil } -func (s *WebsocketServer) getAccountUtxo(descriptor string) (interface{}, error) { - var utxo api.Utxos +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 { - utxo, err = s.api.GetAddressUtxo(descriptor, false) - if(err != nil) { - return utxo, err - } + return s.api.GetAddressUtxo(descriptor, false) } 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) } @@ -554,90 +966,123 @@ 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"` - 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{ - Version: bi.Version, - Subversion: bi.Subversion, - Consensus: bi.Consensus, + Backend: WsBackendInfo{ + Version: bi.Version, + Subversion: bi.Subversion, + ConsensusVersion: bi.ConsensusVersion, + Consensus: bi.Consensus, }, }, 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 + } + 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 } - type estimateFeeRes struct { - FeePerTx string `json:"feePerTx,omitempty"` - FeePerUnit string `json:"feePerUnit,omitempty"` - FeeLimit string `json:"feeLimit,omitempty"` + 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 { return nil, err } sg := strconv.FormatUint(gas, 10) - for i, b := range r.Blocks { - fee, err := s.chain.EstimateSmartFee(b, true) - if err != nil { - return nil, err - } + b := 1 + if len(r.Blocks) > 0 { + b = r.Blocks[0] + } + fee, err := s.api.EstimateFee(b, true) + 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 @@ -657,7 +1102,7 @@ func (s *WebsocketServer) estimateFee(c *websocketChannel, params []byte) (inter } } for i, b := range r.Blocks { - fee, err := s.api.BitcoinTypeEstimateFee(b, conservative) + fee, err := s.api.EstimateFee(b, conservative) if err != nil { return nil, err } @@ -673,8 +1118,28 @@ 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(req WsSendTransactionReq) (res resultSendTransaction, err error) { + // SYSCOIN: preserve upstream raw transaction broadcasts while allowing + // optional Syscoin Core maxfeerate/maxburnamount parameters. + p := bchain.SendRawTransactionParams{Hex: req.Hex, DisableAlternativeRPC: req.DisableAlternativeRPC} + if req.MaxFeeRate != "" { + p.MaxFeeRate = &req.MaxFeeRate + } + if req.MaxBurnAmount != "" { + p.MaxBurnAmount = &req.MaxBurnAmount + } + txid, err := sendRawTransactionWithParams(s.chain, p) if err != nil { return res, err } @@ -682,6 +1147,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"` } @@ -690,11 +1232,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 } @@ -702,18 +1244,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 } @@ -724,36 +1266,61 @@ 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 } - rv := make([]string, len(r.Addresses)) - for i, a := range r.Addresses { + if len(r.Addresses) > limit { + return nil, false, api.NewAPIError("addresses max "+strconv.Itoa(limit), true) + } + rv := make([]string, 0, len(r.Addresses)) + for _, 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 = append(rv, string(ad)) + } + return deduplicateAddressDescriptors(rv), r.NewBlockTxs, nil +} + +func deduplicateAddressDescriptors(addrDesc []string) []string { + if len(addrDesc) < 2 { + return addrDesc + } + seen := make(map[string]struct{}, len(addrDesc)) + rv := addrDesc[:0] + for _, ads := range addrDesc { + if _, exists := seen[ads]; exists { + continue } - rv[i] = string(ad) + seen[ads] = struct{}{} + rv = append(rv, ads) } - return rv, nil + return rv } -// 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) } } @@ -765,7 +1332,11 @@ 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) { + addrDesc = deduplicateAddressDescriptors(addrDesc) s.addressSubscriptionsLock.Lock() defer s.addressSubscriptionsLock.Unlock() // unsubscribe all previous subscriptions @@ -773,13 +1344,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 } @@ -788,11 +1366,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 { @@ -804,16 +1383,20 @@ func (s *WebsocketServer) doUnsubscribeFiatRates(c *websocketChannel) { delete(s.fiatRatesSubscriptions, fr) } } + delete(s.fiatRatesTokenSubscriptions, c) } // subscribeFiatRates subscribes all FiatRates subscriptions by this channel -func (s *WebsocketServer) subscribeFiatRates(c *websocketChannel, currency string, 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 s.doUnsubscribeFiatRates(c) + currency := d.Currency if currency == "" { currency = allFiatRates + } else { + currency = strings.ToLower(currency) } as, ok := s.fiatRatesSubscriptions[currency] if !ok { @@ -821,7 +1404,10 @@ func (s *WebsocketServer) subscribeFiatRates(c *websocketChannel, currency strin s.fiatRatesSubscriptions[currency] = as } as[c] = req.ID - s.metrics.WebsocketSubscribes.With((common.Labels{"method": "subscribeFiatRates"})).Set(float64(len(s.fiatRatesSubscriptions))) + if len(d.Tokens) != 0 { + s.fiatRatesTokenSubscriptions[c] = d.Tokens + } + s.metrics.WebsocketSubscribes.With(common.Labels{"method": "subscribeFiatRates"}).Set(float64(len(s.fiatRatesSubscriptions))) return &subscriptionResponse{true}, nil } @@ -830,7 +1416,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 } @@ -845,24 +1431,185 @@ 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, }) } - glog.Info("broadcasting new block ", height, " ", hash, " to ", len(s.newBlockSubscriptions), " channels") + s.metrics.WebsocketNewBlockNotifications.Add(float64(len(s.newBlockSubscriptions))) + glog.V(2).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, true) + observeNewBlockTxDuration(s.metrics, "match", matchStart) + if len(subscribed) > 0 { + incNewBlockTxMetric(s.metrics, "matched", "success", 1) + if ok, _ := s.trackWork(); !ok { + 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 ok, _ := s.trackWork(); ok { + 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, }) @@ -870,7 +1617,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 { @@ -889,9 +1636,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, }) } @@ -900,45 +1659,69 @@ 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 erc20 - 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 { - subscribed[sad] = struct{}{} - } +func (s *WebsocketServer) getNewTxSubscriptions(vins []bchain.MempoolVin, vouts []bchain.Vout, tokenTransfers bchain.TokenTransfers, internalTransfers []bchain.EthereumInternalTransfer, newBlockTxsOnly bool) map[string]struct{} { + // check if there is any subscription in inputs, outputs and transfers + candidates := make(map[string]struct{}) + addAddrDesc := func(addrDesc bchain.AddressDescriptor) { + if len(addrDesc) > 0 { + candidates[string(addrDesc)] = struct{}{} } } - for i := range tx.Vout { - addrDesc, err := s.chainParser.GetAddrDescFromVout(&tx.Vout[i]) - if err == nil && len(addrDesc) > 0 { - sad := string(addrDesc) - as, ok := s.addressSubscriptions[sad] - if ok && len(as) > 0 { - subscribed[sad] = struct{}{} - } + processAddress := func(address string) { + if addrDesc, err := s.chainParser.GetAddrDescFromAddress(address); err == nil && len(addrDesc) > 0 { + addAddrDesc(addrDesc) } } - for i := range tx.Erc20 { - addrDesc, err := s.chainParser.GetAddrDescFromAddress(tx.Erc20[i].From) - if err == nil && len(addrDesc) > 0 { - sad := string(addrDesc) - as, ok := s.addressSubscriptions[sad] - if ok && len(as) > 0 { - subscribed[sad] = struct{}{} + processVout := func(vout bchain.Vout) { + if addrDesc, err := s.chainParser.GetAddrDescFromVout(&vout); err == nil && len(addrDesc) > 0 { + addAddrDesc(addrDesc) + } + } + for i := range vins { + if sad := string(vins[i].AddrDesc); len(sad) > 0 { + candidates[sad] = struct{}{} + } else { + switch s.chainParser.GetChainType() { + case bchain.ChainBitcoinType: + vout := int(vins[i].Vout) + if vout >= 0 && vout < len(vouts) { + processVout(vouts[vout]) + } + case bchain.ChainEthereumType: + if len(vins[i].Addresses) > 0 { + processAddress(vins[i].Addresses[0]) + } } } - addrDesc, err = s.chainParser.GetAddrDescFromAddress(tx.Erc20[i].To) - if err == nil && len(addrDesc) > 0 { - sad := string(addrDesc) - as, ok := s.addressSubscriptions[sad] - if ok && len(as) > 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) + } + + subscribed := make(map[string]struct{}) + s.addressSubscriptionsLock.Lock() + defer s.addressSubscriptionsLock.Unlock() + for sad := range candidates { + as, ok := s.addressSubscriptions[sad] + if !ok || len(as) == 0 { + continue + } + if !newBlockTxsOnly { + subscribed[sad] = struct{}{} + continue + } + for _, details := range as { + if details.publishNewBlockTxs { subscribed[sad] = struct{}{} + break } } } @@ -953,19 +1736,24 @@ 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, false) if len(s.newTransactionSubscriptions) > 0 || len(subscribed) > 0 { - go s.onNewTxAsync(tx, subscribed) + if ok, _ := s.trackWork(); ok { + go func() { + defer s.workDone() + s.onNewTxAsync(tx, subscribed) + }() + } } } -func (s *WebsocketServer) broadcastTicker(currency string, rates map[string]float64) { +func (s *WebsocketServer) broadcastTicker(currency string, rates map[string]float32, ticker *common.CurrencyRatesTicker) { as, ok := s.fiatRatesSubscriptions[currency] if ok && len(as) > 0 { data := struct { @@ -974,36 +1762,60 @@ func (s *WebsocketServer) broadcastTicker(currency string, rates map[string]floa Rates: rates, } for c, id := range as { - c.DataOut(&websocketRes{ - ID: id, - Data: &data, - }) + var tokens []string + if ticker != nil { + tokens = s.fiatRatesTokenSubscriptions[c] + } + if len(tokens) > 0 { + dataWithTokens := struct { + Rates interface{} `json:"rates"` + TokenRates map[string]float32 `json:"tokenRates,omitempty"` + }{ + Rates: rates, + TokenRates: map[string]float32{}, + } + for _, token := range tokens { + rate := ticker.TokenRateInCurrency(token, currency) + if rate > 0 { + dataWithTokens.TokenRates[token] = rate + } + } + c.DataOut(&WsRes{ + ID: id, + Data: &dataWithTokens, + }) + } else { + c.DataOut(&WsRes{ + ID: id, + Data: &data, + }) + } } glog.Info("broadcasting new rates for currency ", currency, " to ", len(as), " channels") } } // OnNewFiatRatesTicker is a callback that broadcasts info about fiat rates affecting subscribed currency -func (s *WebsocketServer) OnNewFiatRatesTicker(ticker *db.CurrencyRatesTicker) { +func (s *WebsocketServer) OnNewFiatRatesTicker(ticker *common.CurrencyRatesTicker) { s.fiatRatesSubscriptionsLock.Lock() defer s.fiatRatesSubscriptionsLock.Unlock() for currency, rate := range ticker.Rates { - s.broadcastTicker(currency, map[string]float64{currency: rate}) + s.broadcastTicker(currency, map[string]float32{currency: rate}, ticker) } - s.broadcastTicker(allFiatRates, ticker.Rates) + s.broadcastTicker(allFiatRates, ticker.Rates, nil) } -func (s *WebsocketServer) getCurrentFiatRates(currencies []string) (interface{}, error) { - ret, err := s.api.GetCurrentFiatRates(currencies) +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) (interface{}, error) { - ret, err := s.api.GetFiatRatesForTimestamps(timestamps, currencies) +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) getFiatRatesTickersList(timestamp int64) (interface{}, error) { - ret, err := s.api.GetFiatRatesTickersList(timestamp) +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_rate_limiter.go b/server/websocket_rate_limiter.go new file mode 100644 index 0000000000..d072e9e128 --- /dev/null +++ b/server/websocket_rate_limiter.go @@ -0,0 +1,304 @@ +package server + +import ( + "errors" + "net" + "strings" + "sync" + "time" + + "github.com/golang/glog" + "github.com/gorilla/websocket" +) + +const maxWebsocketConnectionAttemptsPerIP = 64 +const maxWebsocketConnectionsPerIP = 128 +const websocketConnectionAttemptWindow = time.Minute +const websocketConnectionLimiterTTL = 10 * time.Minute +const websocketConnectionLimiterCleanupInterval = time.Minute + +// Per-connection message rate limit defaults. A connection sending more than +// defaultWsMessageRateLimit messages within a trailing defaultWsMessageRateWindow is +// closed and its client key blocked for defaultWsIPBlockDuration. The 2500 / 10m +// default sits well above any Trezor Suite burst, so it only trips abusive traffic. +const defaultWsMessageRateLimit = 2500 +const defaultWsMessageRateWindow = 10 * time.Minute +const defaultWsIPBlockDuration = 12 * time.Hour + +// messageRateWindowBuckets is the number of sub-buckets the message rate window +// is divided into. It bounds per-connection memory (a fixed array, independent +// of the limit) and sets the sliding-window resolution: with a 10-minute window +// this is one 10-second bucket, so the count can over-shoot the true trailing +// window by at most one bucket's worth of traffic. +const messageRateWindowBuckets = 60 + +type websocketClientLimit struct { + active int + attempts []time.Time + lastSeen time.Time +} + +type websocketConnectionLimiter struct { + mux sync.Mutex + clients map[string]*websocketClientLimit + lastCleanup time.Time +} + +// configureMessageRateLimit reads the per-connection message-rate and IP-block +// config from the environment, applying defaults (see docs/env.md for the vars). +func (s *WebsocketServer) configureMessageRateLimit(network string) error { + prefix := strings.ToUpper(network) + var err error + if s.messageRateLimit, err = parseNonNegativeIntEnv(prefix+"_WS_MESSAGE_RATE_LIMIT", defaultWsMessageRateLimit); err != nil { + return err + } + if s.messageRateWindow, err = parsePositiveDurationEnv(prefix+"_WS_MESSAGE_RATE_WINDOW", defaultWsMessageRateWindow); err != nil { + return err + } + if s.ipBlockDuration, err = parseNonNegativeDurationEnv(prefix+"_WS_IP_BLOCK_DURATION", defaultWsIPBlockDuration); err != nil { + return err + } + s.ipBlockEnabled = s.messageRateLimit > 0 && s.ipBlockDuration > 0 + if s.messageRateLimit > 0 { + glog.Infof("Websocket per-connection message rate limit: %d messages / %s; offending IP block: %s", + s.messageRateLimit, s.messageRateWindow, s.ipBlockDuration) + if s.ipBlockDuration > 0 && len(s.cloudflarePrefixes) == 0 { + glog.Warning("Websocket IP block is enabled without Cloudflare peer verification; behind Cloudflare set _CLOUDFLARE_IPS=builtin so blocks key on the real client IP rather than being skipped for unverified forwarded headers") + } + } else { + glog.Info("Websocket per-connection message rate limit disabled") + } + return nil +} + +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) +} + +// stats returns the number of distinct client IPs that currently hold at least +// one active websocket connection and the largest per-IP connection count. The +// snapshot is taken under the limiter lock; idle entries retained for the TTL +// window are skipped so the numbers track live connections, not recent history. +func (l *websocketConnectionLimiter) stats() (uniqueActiveIPs int, maxConnectionsPerIP int) { + l.mux.Lock() + defer l.mux.Unlock() + for _, client := range l.clients { + if client.active <= 0 { + continue + } + uniqueActiveIPs++ + if client.active > maxConnectionsPerIP { + maxConnectionsPerIP = client.active + } + } + return uniqueActiveIPs, maxConnectionsPerIP +} + +// runWebsocketLimiterMaintenance ticks every interval to sweep TTL-expired +// entries from the connection limiter and to publish the per-IP clustering +// gauges. It does not terminate; it is started once per WebsocketServer at +// construction time and runs for the lifetime of the process. +func (s *WebsocketServer) runWebsocketLimiterMaintenance(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for now := range ticker.C { + s.websocketLimiter.sweep(now) + blockedIPs := 0 + if s.ipBlockEnabled { + blockedIPs = s.is.SweepWsBlockedIPs(now) + } + if s.metrics != nil { + uniqueIPs, maxConnectionsPerIP := s.websocketLimiter.stats() + s.metrics.WebsocketUniqueIPs.Set(float64(uniqueIPs)) + s.metrics.WebsocketMaxConnectionsPerIP.Set(float64(maxConnectionsPerIP)) + s.metrics.WebsocketBlockedIPs.Set(float64(blockedIPs)) + } + } +} + +func (client *websocketClientLimit) trimAttempts(now time.Time) { + client.attempts = trimTimes(client.attempts, now.Add(-websocketConnectionAttemptWindow)) +} + +// connMessageRate is a fixed-memory, bucketed sliding-window message counter +// owned by a single websocket connection. It approximates the number of +// messages seen in the trailing window by summing messageRateWindowBuckets +// sub-buckets; the running count can over-shoot the true window by at most one +// bucket's worth of traffic. It is NOT safe for concurrent use: only the +// connection's inputLoop goroutine calls observe(). +type connMessageRate struct { + bucketDur time.Duration + counts [messageRateWindowBuckets]int32 + total int32 + lastBucket int64 // absolute index of the most recently touched bucket + inited bool +} + +func newConnMessageRate(window time.Duration) *connMessageRate { + bucketDur := window / messageRateWindowBuckets + if bucketDur <= 0 { + bucketDur = 1 + } + return &connMessageRate{bucketDur: bucketDur} +} + +// observe records one message at time now and returns the approximate number of +// messages seen in the trailing window, including this one. +func (m *connMessageRate) observe(now time.Time) int { + idx := now.UnixNano() / int64(m.bucketDur) + if !m.inited { + m.inited = true + m.lastBucket = idx + } + if idx < m.lastBucket { + // Clock moved backwards; fold into the current bucket rather than + // rewriting history. + idx = m.lastBucket + } + if advance := idx - m.lastBucket; advance > 0 { + // Zero the buckets we are advancing into; they hold stale counts from a + // previous window. Capping at the ring size clears the whole window. + if advance > messageRateWindowBuckets { + advance = messageRateWindowBuckets + } + for i := int64(1); i <= advance; i++ { + slot := (m.lastBucket + i) % messageRateWindowBuckets + m.total -= m.counts[slot] + m.counts[slot] = 0 + } + m.lastBucket = idx + } + slot := idx % messageRateWindowBuckets + m.counts[slot]++ + m.total++ + return int(m.total) +} + +// errWsMessageRateExceeded aborts the read loop from a control-frame handler; +// by the time it surfaces in inputLoop as a read error, onMessageRateBreach has +// already closed the channel (and blocked the key when blockable). +var errWsMessageRateExceeded = errors.New("websocket message rate limit exceeded") + +// installControlFrameRateLimit counts client ping/pong control frames toward +// the per-connection message rate limit. gorilla consumes control frames inside +// ReadMessage and dispatches them to these handlers, so they never reach +// inputLoop's switch; without this a client could stream pings -- each costing +// the server a read plus a pong write -- without the limiter ever seeing them. +// The handlers run on the connection's read goroutine, the same one that +// observes text messages, so the counter stays single-goroutine. The ping +// handler otherwise mirrors gorilla's default: answer with a pong carrying the +// ping payload, swallowing closed-connection and write-timeout errors. +func (s *WebsocketServer) installControlFrameRateLimit(c *websocketChannel) { + c.conn.SetPingHandler(func(message string) error { + if count := c.messageRate.observe(time.Now()); count > s.messageRateLimit { + s.onMessageRateBreach(c, count) + return errWsMessageRateExceeded + } + err := c.conn.WriteControl(websocket.PongMessage, []byte(message), time.Now().Add(time.Second)) + if err == websocket.ErrCloseSent { + return nil + } + if e, ok := err.(net.Error); ok && e.Timeout() { + return nil + } + return err + }) + c.conn.SetPongHandler(func(string) error { + if count := c.messageRate.observe(time.Now()); count > s.messageRateLimit { + s.onMessageRateBreach(c, count) + return errWsMessageRateExceeded + } + return nil + }) +} + +// onMessageRateBreach handles a connection that exceeded the per-connection +// message rate limit: it flags the client key for the configured block duration +// (when the attribution is trustworthy and blockable) and closes the connection. +// The connection is always closed; only the per-IP block is conditional. +func (s *WebsocketServer) onMessageRateBreach(c *websocketChannel, count int) { + now := time.Now() + blocked := false + if s.ipBlockEnabled && c.blockable { + s.is.BlockWsIP(c.blockKey, now.Add(s.ipBlockDuration), now) + blocked = true + } + closed := s.closeChannel(c, "message_rate_limit") + // The block takes effect regardless of which goroutine wins the close race, + // so it is logged unconditionally; the close-only message is logged just by + // the winner to avoid duplicates. + if blocked { + glog.Warning("Client ", c.id, " exceeded websocket message rate limit (", count, "/", s.messageRateLimit, + "); blocking ", c.blockKey, " for ", s.ipBlockDuration) + } else if closed { + glog.Warning("Client ", c.id, " exceeded websocket message rate limit (", count, "/", s.messageRateLimit, + "); closing connection (", c.ip, " not blockable)") + } +} diff --git a/server/websocket_test.go b/server/websocket_test.go new file mode 100644 index 0000000000..fa98bbcef9 --- /dev/null +++ b/server/websocket_test.go @@ -0,0 +1,1357 @@ +//go:build unittest +// +build unittest + +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/netip" + "os" + "path/filepath" + "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 TestParseCloudflareProxies(t *testing.T) { + const envName = "TEST_WS_CLOUDFLARE_IPS" + // unset and the builtin spellings resolve to the embedded edge list + for _, v := range []string{"", "builtin", "Default"} { + got, err := parseCloudflareProxies(envName, v) + if err != nil || len(got) == 0 { + t.Fatalf("parseCloudflareProxies(%q) = %d prefixes, err %v; want the embedded list, nil", v, len(got), err) + } + // spot-check long-standing Cloudflare ranges from cloudflare_ips.txt + if !prefixesContain(got, "104.16.0.0/13") || !prefixesContain(got, "2606:4700::/32") { + t.Fatalf("parseCloudflareProxies(%q) is missing known Cloudflare ranges: %v", v, got) + } + } + // a value starting with @ loads the list from a file: one CIDR per line, + // commas also accepted, blank lines and #-comments ignored + cidrFile := filepath.Join(t.TempDir(), "cf.txt") + if err := os.WriteFile(cidrFile, []byte("# test ranges\n203.0.113.0/24\n\n2400:cb00::/32, 198.51.100.0/24 # trailing comment\n"), 0o600); err != nil { + t.Fatal(err) + } + fromFile, err := parseCloudflareProxies(envName, "@"+cidrFile) + if err != nil || len(fromFile) != 3 || !prefixesContain(fromFile, "203.0.113.0/24") || !prefixesContain(fromFile, "198.51.100.0/24") { + t.Fatalf("file list = %v, err %v; want the three prefixes from the file", fromFile, err) + } + // a missing file and a file with no CIDRs must fail startup rather than + // silently disabling verification + if _, err := parseCloudflareProxies(envName, "@"+filepath.Join(t.TempDir(), "missing.txt")); err == nil { + t.Fatal("missing CIDR file: expected error, got nil") + } + if err := os.WriteFile(cidrFile, []byte("# only comments\n\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := parseCloudflareProxies(envName, "@"+cidrFile); err == nil { + t.Fatal("CIDR file without CIDRs: expected error, got nil") + } + // the off spellings disable verification + for _, v := range []string{"off", "none", "false", "0", "disabled", " OFF "} { + got, err := parseCloudflareProxies(envName, v) + if err != nil || got != nil { + t.Fatalf("parseCloudflareProxies(%q) = %v, err %v; want nil, nil", v, got, err) + } + } + // a custom list replaces the built-in ranges + custom, err := parseCloudflareProxies(envName, "203.0.113.0/24, 2400:cb00::/32") + if err != nil || len(custom) != 2 || custom[0] != netip.MustParsePrefix("203.0.113.0/24") { + t.Fatalf("custom list = %v, err %v; want the two configured prefixes", custom, err) + } + // invalid CIDRs, IPv4-mapped notation, and a list with no CIDRs at all must + // fail rather than silently disabling verification + for _, v := range []string{"not-a-cidr", "::ffff:192.0.2.0/120", ", ,"} { + if _, err := parseCloudflareProxies(envName, v); err == nil { + t.Fatalf("parseCloudflareProxies(%q) expected error, got nil", v) + } + } +} + +func TestResolveClientIPLegacyAndTrustedProxy(t *testing.T) { + tests := []struct { + name string + headers map[string]string + remoteAddr string + trusted []netip.Prefix + cloudflare []netip.Prefix + pseudoIPv6 bool + want string + }{ + { + name: "cloudflare ip is preferred over spoofable ipv6 header by default", + headers: map[string]string{ + "CF-Connecting-IPv6": "2001:db8::1", + "CF-Connecting-IP": "192.0.2.10", + }, + remoteAddr: "198.51.100.1:12345", + want: "192.0.2.10", + }, + { + name: "cloudflare ipv6 preferred only with pseudo-ipv4 opt-in", + headers: map[string]string{ + "CF-Connecting-IPv6": "2001:db8::1", + "CF-Connecting-IP": "192.0.2.10", + }, + remoteAddr: "198.51.100.1:12345", + pseudoIPv6: true, + 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 is NOT implicitly trusted; X-Real-Ip ignored", + headers: map[string]string{ + "X-Real-Ip": "203.0.113.60", + }, + remoteAddr: "[fe80::1%eth0]:12345", + want: "fe80::1", // header ignored, falls back to the (zone-stripped) peer + }, + { + name: "link-local IPv6 peer trusted only when listed explicitly; zone stripped for matching", + headers: map[string]string{ + "X-Real-Ip": "203.0.113.60", + }, + remoteAddr: "[fe80::1%eth0]:12345", + trusted: []netip.Prefix{netip.MustParsePrefix("fe80::1/128")}, + 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, _, _ := resolveClientIP(r, tt.trusted, tt.cloudflare, tt.pseudoIPv6) + if got != tt.want { + t.Fatalf("resolveClientIP() = %q, want %q", got, tt.want) + } + }) + } +} + +// mustParsePrefixes is a test helper that parses CIDR strings into prefixes. +func mustParsePrefixes(t *testing.T, cidrs ...string) []netip.Prefix { + t.Helper() + out := make([]netip.Prefix, 0, len(cidrs)) + for _, c := range cidrs { + out = append(out, netip.MustParsePrefix(c)) + } + return out +} + +func TestResolveClientIPCloudflareVerification(t *testing.T) { + cf := mustParsePrefixes(t, "203.0.113.0/24", "2400:cb00::/32") + trusted := mustParsePrefixes(t, "198.51.100.0/24") + tests := []struct { + name string + headers map[string]string + remoteAddr string + trusted []netip.Prefix + cloudflare []netip.Prefix + pseudoIPv6 bool + want string + wantBlockSafe bool + }{ + { + name: "verified cloudflare peer: CF header trusted and block-safe", + headers: map[string]string{"CF-Connecting-IP": "192.0.2.10"}, + remoteAddr: "203.0.113.5:443", // inside configured CF range + cloudflare: cf, + want: "192.0.2.10", + wantBlockSafe: true, + }, + { + name: "spoofed CF-Connecting-IPv6 ignored by default; CF-Connecting-IP used", + headers: map[string]string{ + "CF-Connecting-IPv6": "2001:db8:dead::1", + "CF-Connecting-IP": "192.0.2.10", + }, + remoteAddr: "203.0.113.5:443", + cloudflare: cf, + want: "192.0.2.10", + wantBlockSafe: true, + }, + { + name: "pseudo-ipv4 opt-in: CF-Connecting-IPv6 preferred over synthetic CF-Connecting-IP", + headers: map[string]string{ + "CF-Connecting-IPv6": "2001:db8:beef::1", + "CF-Connecting-IP": "192.0.2.10", + }, + remoteAddr: "203.0.113.5:443", + cloudflare: cf, + pseudoIPv6: true, + want: "2001:db8:beef::1", + wantBlockSafe: true, + }, + { + name: "verified peer, only spoofed CF-Connecting-IPv6, default: header ignored, peer not block-safe", + headers: map[string]string{"CF-Connecting-IPv6": "2001:db8:dead::1"}, + remoteAddr: "203.0.113.5:443", + cloudflare: cf, + want: "203.0.113.5", + wantBlockSafe: false, // an untrusted CF header was present: do not block the peer + }, + { + name: "pseudo-ipv4 opt-in falls back to CF-Connecting-IP when IPv6 header absent", + headers: map[string]string{ + "CF-Connecting-IP": "192.0.2.10", + }, + remoteAddr: "203.0.113.5:443", + cloudflare: cf, + pseudoIPv6: true, + want: "192.0.2.10", + wantBlockSafe: true, + }, + { + name: "unverified public peer: CF header ignored, peer not block-safe (spoof guard)", + headers: map[string]string{"CF-Connecting-IP": "192.0.2.10"}, + remoteAddr: "198.51.100.7:443", // NOT a CF range + cloudflare: cf, + want: "198.51.100.7", + wantBlockSafe: false, // an untrusted CF header was present: do not block the peer + }, + { + name: "loopback proxy fronting cloudflare: CF header trusted and block-safe", + headers: map[string]string{"CF-Connecting-IP": "192.0.2.20"}, + remoteAddr: "127.0.0.1:5000", + cloudflare: cf, + want: "192.0.2.20", + wantBlockSafe: true, + }, + { + name: "verification disabled: CF header trusted for rate limit but NOT block-safe", + headers: map[string]string{"CF-Connecting-IP": "192.0.2.30"}, + remoteAddr: "198.51.100.7:443", + cloudflare: nil, + want: "192.0.2.30", + wantBlockSafe: false, // spoofable without peer verification + }, + { + name: "native IPv6 in CF-Connecting-IP without IPv6 header", + headers: map[string]string{"CF-Connecting-IP": "2001:db8::99"}, + remoteAddr: "203.0.113.5:443", + cloudflare: cf, + want: "2001:db8::99", + wantBlockSafe: true, + }, + { + name: "malformed CF-Connecting-IPv6 falls through to CF-Connecting-IP", + headers: map[string]string{ + "CF-Connecting-IPv6": "not-an-ip", + "CF-Connecting-IP": "192.0.2.40", + }, + remoteAddr: "203.0.113.5:443", + cloudflare: cf, + want: "192.0.2.40", + wantBlockSafe: true, + }, + { + name: "X-Real-Ip from trusted proxy is block-safe", + headers: map[string]string{"X-Real-Ip": "192.0.2.50"}, + remoteAddr: "198.51.100.7:443", // inside configured trusted-proxy range + trusted: trusted, + want: "192.0.2.50", + wantBlockSafe: true, + }, + { + name: "direct public peer with no forwarding header is block-safe", + remoteAddr: "192.0.2.60:443", + cloudflare: cf, + want: "192.0.2.60", + wantBlockSafe: true, + }, + } + 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, blockSafe, _ := resolveClientIP(r, tt.trusted, tt.cloudflare, tt.pseudoIPv6) + if got != tt.want || blockSafe != tt.wantBlockSafe { + t.Fatalf("resolveClientIP() = %q, %v, want %q, %v", got, blockSafe, tt.want, tt.wantBlockSafe) + } + }) + } +} + +func TestResolveClientIPFromHeader(t *testing.T) { + cf := mustParsePrefixes(t, "203.0.113.0/24") + tests := []struct { + name string + headers map[string]string + remoteAddr string + wantFromHeader bool + }{ + { + name: "CF header honored from verified peer", + headers: map[string]string{"CF-Connecting-IP": "192.0.2.10"}, + remoteAddr: "203.0.113.5:443", + wantFromHeader: true, + }, + { + name: "X-Real-Ip honored from loopback proxy", + headers: map[string]string{"X-Real-Ip": "192.0.2.11"}, + remoteAddr: "127.0.0.1:5000", + wantFromHeader: true, + }, + { + name: "untrusted CF header falls back to bare peer", + headers: map[string]string{"CF-Connecting-IP": "192.0.2.10"}, + remoteAddr: "198.51.100.7:443", + wantFromHeader: false, + }, + { + name: "bare loopback peer without headers", + remoteAddr: "127.0.0.1:5000", + wantFromHeader: false, + }, + } + 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) + } + if _, _, fromHeader := resolveClientIP(r, nil, cf, false); fromHeader != tt.wantFromHeader { + t.Fatalf("resolveClientIP() fromHeader = %v, want %v", fromHeader, tt.wantFromHeader) + } + }) + } +} + +func TestRateLimitKey(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"192.0.2.10", "192.0.2.10"}, + {"::ffff:192.0.2.10", "192.0.2.10"}, // IPv4-mapped IPv6 unmaps to the IPv4 key + {"2001:db8:1:2:3:4:5:6", "2001:db8:1:2::/64"}, + {"2001:db8:1:2::ffff", "2001:db8:1:2::/64"}, + {"2001:db8:1:3::1", "2001:db8:1:3::/64"}, + {"not-an-ip", "not-an-ip"}, + } + for _, tt := range tests { + if got := rateLimitKey(tt.in); got != tt.want { + t.Fatalf("rateLimitKey(%q) = %q, want %q", tt.in, got, tt.want) + } + } + // addresses within the same /64 must share a key, distinct /64s must not + if rateLimitKey("2001:db8:1:2::1") != rateLimitKey("2001:db8:1:2::2") { + t.Fatal("addresses in the same /64 should share a rate-limit key") + } + if rateLimitKey("2001:db8:1:2::1") == rateLimitKey("2001:db8:1:3::1") { + t.Fatal("addresses in different /64s should not share a rate-limit key") + } +} + +func TestBlockKey(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"192.0.2.10", "192.0.2.10"}, // IPv4 verbatim (== rateLimitKey) + {"::ffff:192.0.2.10", "192.0.2.10"}, // IPv4-mapped IPv6 unmaps to the IPv4 key + {"2001:db8:1:2:3:4:5:6", "2001:db8:1:2:3:4:5:6"}, // IPv6 kept at the full /128 + {"2001:db8:1:2::ffff", "2001:db8:1:2::ffff"}, + {"[2001:db8:1:2::1%eth0]", "[2001:db8:1:2::1%eth0]"}, // brackets/zone are not a bare addr; verbatim + {"2001:db8:1:2::1%eth0", "2001:db8:1:2::1"}, // zone stripped from a bare addr + {"not-an-ip", "not-an-ip"}, + } + for _, tt := range tests { + if got := blockKey(tt.in); got != tt.want { + t.Fatalf("blockKey(%q) = %q, want %q", tt.in, got, tt.want) + } + } + // The whole point of decoupling: two addresses in one /64 share a rate-limit + // key (so the limiter aggregates) but get distinct block keys (so a block on + // one does not take out the other). + a, b := "2001:db8:1:2::1", "2001:db8:1:2::2" + if rateLimitKey(a) != rateLimitKey(b) { + t.Fatal("same /64 should share a rate-limit key") + } + if blockKey(a) == blockKey(b) { + t.Fatal("distinct /128s in the same /64 must get distinct block keys") + } + // IPv4 block key must equal its rate-limit key (IPv4 behavior unchanged). + if blockKey("192.0.2.10") != rateLimitKey("192.0.2.10") { + t.Fatal("IPv4 block key should equal its rate-limit key") + } +} + +func TestIsBlockableKey(t *testing.T) { + cf := mustParsePrefixes(t, "203.0.113.0/24") + trusted := mustParsePrefixes(t, "198.51.100.0/24") + tests := []struct { + ip string + want bool + }{ + {"192.0.2.10", true}, // ordinary public address + {"2001:db8:1:2::5", true}, // ordinary public IPv6 address + {"127.0.0.1", false}, // loopback + {"10.1.2.3", false}, // RFC1918 + {"192.168.1.1", false}, // RFC1918 + {"169.254.0.1", false}, // link-local + {"203.0.113.9", false}, // inside Cloudflare range + {"198.51.100.9", false}, // inside trusted-proxy range + {"::ffff:10.1.2.3", false}, // IPv4-mapped private unmaps before the checks + {"::ffff:203.0.113.9", false}, // IPv4-mapped form of a Cloudflare-range address + {"not-an-ip", false}, // unparseable + } + for _, tt := range tests { + if got := isBlockableKey(tt.ip, trusted, cf); got != tt.want { + t.Fatalf("isBlockableKey(%q) = %v, want %v", tt.ip, got, tt.want) + } + } +} + +func TestConnMessageRate(t *testing.T) { + base := time.Unix(1_700_000_000, 0) + m := newConnMessageRate(10 * time.Minute) + + // 100 messages in the same instant accumulate. + var last int + for i := 0; i < 100; i++ { + last = m.observe(base) + } + if last != 100 { + t.Fatalf("after 100 messages at one instant, count = %d, want 100", last) + } + + // Messages spread across the window keep accumulating (sliding, not reset). + m2 := newConnMessageRate(10 * time.Minute) + count := 0 + for i := 0; i < 60; i++ { + count = m2.observe(base.Add(time.Duration(i) * 5 * time.Second)) // 0..295s + } + if count != 60 { + t.Fatalf("60 messages within the window, count = %d, want 60", count) + } + + // Once the full window has elapsed since the first message, old buckets drop + // out of the trailing window. + after := m2.observe(base.Add(10*time.Minute + time.Second)) + if after >= 60 { + t.Fatalf("after the window elapsed the count should drop, got %d", after) + } + + // A gap longer than the whole window resets the counter. + reset := m2.observe(base.Add(time.Hour)) + if reset != 1 { + t.Fatalf("after a gap longer than the window, count = %d, want 1", reset) + } +} + +func TestConnMessageRateClockSkew(t *testing.T) { + base := time.Unix(1_700_000_000, 0) + m := newConnMessageRate(10 * time.Minute) + m.observe(base.Add(time.Minute)) + // A backwards clock jump must not panic or rewrite history; it folds into the + // current bucket. + if got := m.observe(base); got != 2 { + t.Fatalf("backwards clock observe = %d, want 2", got) + } +} + +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 TestWebsocketConnectionLimiterStats(t *testing.T) { + limiter := newWebsocketConnectionLimiter() + now := time.Unix(1700000000, 0) + a := "192.0.2.50" + b := "192.0.2.51" + + if unique, max := limiter.stats(); unique != 0 || max != 0 { + t.Fatalf("stats() on empty limiter = %d, %d, want 0, 0", unique, max) + } + + for i := 0; i < 3; i++ { + if ok, reason := limiter.accept(a, now); !ok { + t.Fatalf("accept(a, %d) rejected with %q", i, reason) + } + } + if ok, reason := limiter.accept(b, now); !ok { + t.Fatalf("accept(b) rejected with %q", reason) + } + + // two distinct IPs hold connections; the busiest holds three + if unique, max := limiter.stats(); unique != 2 || max != 3 { + t.Fatalf("stats() = %d, %d, want 2, 3", unique, max) + } + + // releasing every connection from a leaves an idle (active==0) entry that + // is still tracked for the TTL window; stats must not count it as a cluster + for i := 0; i < 3; i++ { + limiter.release(a, now) + } + limiter.mux.Lock() + _, aStillTracked := limiter.clients[a] + limiter.mux.Unlock() + if !aStillTracked { + t.Fatal("released entry should remain tracked within the TTL window") + } + if unique, max := limiter.stats(); unique != 1 || max != 1 { + t.Fatalf("stats() after releasing idle IP = %d, %d, want 1, 1", unique, max) + } +} + +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 TestUnmarshalAddressesDeduplicatesDescriptors(t *testing.T) { + parser, _ := setupChain(t) + params, err := json.Marshal(WsSubscribeAddressesReq{ + Addresses: []string{dbtestdata.Addr1, dbtestdata.Addr1}, + }) + if err != nil { + t.Fatal(err) + } + + s := &WebsocketServer{chainParser: parser} + addresses, newBlockTxs, err := s.unmarshalAddresses(params) + if err != nil { + t.Fatal(err) + } + if newBlockTxs { + t.Fatal("newBlockTxs = true, want false") + } + if len(addresses) != 1 { + t.Fatalf("len(addresses) = %d, want 1", len(addresses)) + } +} + +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, true) + 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, true) + if _, ok := withResolvedVins[string(addr3Desc)]; !ok { + t.Fatal("sender subscription did not match after vin descriptor resolution") + } +} + +func TestGetNewTxSubscriptionsFiltersMempoolOnlyForNewBlockTxs(t *testing.T) { + parser, _ := setupChain(t) + addrDesc, err := parser.GetAddrDescFromAddress(dbtestdata.Addr1) + if err != nil { + t.Fatal(err) + } + stringAddrDesc := string(addrDesc) + dummy := &websocketChannel{} + s := &WebsocketServer{ + addressSubscriptions: map[string]map[*websocketChannel]*addressDetails{ + stringAddrDesc: {dummy: {requestID: "mempool-only", publishNewBlockTxs: false}}, + }, + } + vins := []bchain.MempoolVin{{AddrDesc: addrDesc}} + + mempoolSubscribed := s.getNewTxSubscriptions(vins, nil, nil, nil, false) + if _, ok := mempoolSubscribed[stringAddrDesc]; !ok { + t.Fatal("mempool notification did not match mempool-only subscriber") + } + newBlockSubscribed := s.getNewTxSubscriptions(vins, nil, nil, nil, true) + if _, ok := newBlockSubscribed[stringAddrDesc]; ok { + t.Fatal("newBlockTxs matching included mempool-only subscriber") + } + + s.addressSubscriptions[stringAddrDesc][dummy].publishNewBlockTxs = true + newBlockSubscribed = s.getNewTxSubscriptions(vins, nil, nil, nil, true) + if _, ok := newBlockSubscribed[stringAddrDesc]; !ok { + t.Fatal("newBlockTxs matching did not include newBlockTxs subscriber") + } +} + +func newShutdownTestServer() *WebsocketServer { + return &WebsocketServer{activeChannels: make(map[*websocketChannel]struct{})} +} + +func TestWebsocketShutdownWaitsForInFlightWork(t *testing.T) { + s := newShutdownTestServer() + if ok, reason := s.trackWork(); !ok { + t.Fatalf("trackWork() returned false before shutdown, reason %q", reason) + } + + 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 ok, reason := s.trackWork(); !ok { + t.Fatalf("trackWork() returned false before shutdown, reason %q", reason) + } + + 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 ok, reason := s.trackWork(); ok || reason != "server_shutdown" { + t.Fatalf("trackWork() = %v, %q after shutdown, want false, server_shutdown", ok, reason) + } + dummy := &websocketChannel{} + if s.registerChannel(dummy) { + t.Fatal("registerChannel() returned true after shutdown") + } +} + +func TestWebsocketTrackWorkAppliesGlobalLimit(t *testing.T) { + s := newShutdownTestServer() + s.activeRequests = maxWebsocketActiveRequests + if ok, reason := s.trackWork(); ok || reason != "work_limit" { + t.Fatalf("trackWork() = %v, %q at global limit, want false, work_limit", ok, reason) + } + + s.activeRequests = 0 + if ok, reason := s.trackWork(); !ok || reason != "" { + t.Fatalf("trackWork() = %v, %q below global limit, want true, empty reason", ok, reason) + } + s.workDone() + if s.activeRequests != 0 { + t.Fatalf("activeRequests = %d after workDone, want 0", s.activeRequests) + } +} + +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..3f746201e8 --- /dev/null +++ b/server/ws_types.go @@ -0,0 +1,207 @@ +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."` + ConfirmedNonce bool `json:"confirmedNonce,omitempty" ts_doc:"If true, additionally return the confirmed nonce for Ethereum-like addresses (extra backend call)."` +} + +// 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."` + // SYSCOIN: optional Syscoin Core sendrawtransaction parameters. + MaxFeeRate string `json:"maxfeerate,omitempty" ts_doc:"Optional Syscoin Core maxfeerate parameter."` + MaxBurnAmount string `json:"maxburnamount,omitempty" ts_doc:"Optional Syscoin Core maxburnamount parameter."` +} + +// 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/api-docs/index.html b/static/api-docs/index.html new file mode 100644 index 0000000000..2823f179a2 --- /dev/null +++ b/static/api-docs/index.html @@ -0,0 +1,20 @@ + + + + + + + Blockbook API Docs + + + +
+ + + + diff --git a/static/api-docs/swagger-init.js b/static/api-docs/swagger-init.js new file mode 100644 index 0000000000..fda22ab13e --- /dev/null +++ b/static/api-docs/swagger-init.js @@ -0,0 +1,22 @@ +(function () { + "use strict"; + + var element = document.getElementById("swagger-ui"); + if (!element || typeof SwaggerUIBundle !== "function") { + return; + } + + window.ui = SwaggerUIBundle({ + url: element.getAttribute("data-openapi-url") || "./openapi.yaml", + dom_id: "#swagger-ui", + deepLinking: true, + docExpansion: "list", + defaultModelsExpandDepth: 1, + validatorUrl: null, + supportedSubmitMethods: [], + presets: [ + SwaggerUIBundle.presets.apis, + ], + layout: "BaseLayout", + }); +}()); diff --git a/static/css/TTHoves/TTHoves-Black.woff b/static/css/TTHoves/TTHoves-Black.woff new file mode 100644 index 0000000000..e30243577e Binary files /dev/null and b/static/css/TTHoves/TTHoves-Black.woff differ diff --git a/static/css/TTHoves/TTHoves-Black.woff2 b/static/css/TTHoves/TTHoves-Black.woff2 new file mode 100644 index 0000000000..c815059747 Binary files /dev/null and b/static/css/TTHoves/TTHoves-Black.woff2 differ diff --git a/static/css/TTHoves/TTHoves-Bold.woff b/static/css/TTHoves/TTHoves-Bold.woff new file mode 100644 index 0000000000..96630ca3cf Binary files /dev/null and b/static/css/TTHoves/TTHoves-Bold.woff differ diff --git a/static/css/TTHoves/TTHoves-Bold.woff2 b/static/css/TTHoves/TTHoves-Bold.woff2 new file mode 100644 index 0000000000..8f1fd69015 Binary files /dev/null and b/static/css/TTHoves/TTHoves-Bold.woff2 differ diff --git a/static/css/TTHoves/TTHoves-BoldItalic.woff b/static/css/TTHoves/TTHoves-BoldItalic.woff new file mode 100644 index 0000000000..3c76f859b6 Binary files /dev/null and b/static/css/TTHoves/TTHoves-BoldItalic.woff differ diff --git a/static/css/TTHoves/TTHoves-BoldItalic.woff2 b/static/css/TTHoves/TTHoves-BoldItalic.woff2 new file mode 100644 index 0000000000..5fa30f302e Binary files /dev/null and b/static/css/TTHoves/TTHoves-BoldItalic.woff2 differ diff --git a/static/css/TTHoves/TTHoves-DemiBold.woff b/static/css/TTHoves/TTHoves-DemiBold.woff new file mode 100644 index 0000000000..6dc9af24c3 Binary files /dev/null and b/static/css/TTHoves/TTHoves-DemiBold.woff differ diff --git a/static/css/TTHoves/TTHoves-DemiBold.woff2 b/static/css/TTHoves/TTHoves-DemiBold.woff2 new file mode 100644 index 0000000000..ca31b22821 Binary files /dev/null and b/static/css/TTHoves/TTHoves-DemiBold.woff2 differ diff --git a/static/css/TTHoves/TTHoves-ExtraBold.woff b/static/css/TTHoves/TTHoves-ExtraBold.woff new file mode 100644 index 0000000000..43ab1969a7 Binary files /dev/null and b/static/css/TTHoves/TTHoves-ExtraBold.woff differ diff --git a/static/css/TTHoves/TTHoves-ExtraBold.woff2 b/static/css/TTHoves/TTHoves-ExtraBold.woff2 new file mode 100644 index 0000000000..c37df5c4ec Binary files /dev/null and b/static/css/TTHoves/TTHoves-ExtraBold.woff2 differ diff --git a/static/css/TTHoves/TTHoves-ExtraLight.woff b/static/css/TTHoves/TTHoves-ExtraLight.woff new file mode 100644 index 0000000000..e7e8aa2337 Binary files /dev/null and b/static/css/TTHoves/TTHoves-ExtraLight.woff differ diff --git a/static/css/TTHoves/TTHoves-ExtraLight.woff2 b/static/css/TTHoves/TTHoves-ExtraLight.woff2 new file mode 100644 index 0000000000..15db8acd73 Binary files /dev/null and b/static/css/TTHoves/TTHoves-ExtraLight.woff2 differ diff --git a/static/css/TTHoves/TTHoves-Light.woff b/static/css/TTHoves/TTHoves-Light.woff new file mode 100644 index 0000000000..be30da280d Binary files /dev/null and b/static/css/TTHoves/TTHoves-Light.woff differ diff --git a/static/css/TTHoves/TTHoves-Light.woff2 b/static/css/TTHoves/TTHoves-Light.woff2 new file mode 100644 index 0000000000..4a57aeb4eb Binary files /dev/null and b/static/css/TTHoves/TTHoves-Light.woff2 differ diff --git a/static/css/TTHoves/TTHoves-Medium.woff b/static/css/TTHoves/TTHoves-Medium.woff new file mode 100644 index 0000000000..3277fb8ac8 Binary files /dev/null and b/static/css/TTHoves/TTHoves-Medium.woff differ diff --git a/static/css/TTHoves/TTHoves-Medium.woff2 b/static/css/TTHoves/TTHoves-Medium.woff2 new file mode 100644 index 0000000000..3dae893a1a Binary files /dev/null and b/static/css/TTHoves/TTHoves-Medium.woff2 differ diff --git a/static/css/TTHoves/TTHoves-Regular.woff b/static/css/TTHoves/TTHoves-Regular.woff new file mode 100644 index 0000000000..dd7b6fe5d0 Binary files /dev/null and b/static/css/TTHoves/TTHoves-Regular.woff differ diff --git a/static/css/TTHoves/TTHoves-Regular.woff2 b/static/css/TTHoves/TTHoves-Regular.woff2 new file mode 100644 index 0000000000..a2cddb2908 Binary files /dev/null and b/static/css/TTHoves/TTHoves-Regular.woff2 differ diff --git a/static/css/TTHoves/TTHoves.css b/static/css/TTHoves/TTHoves.css new file mode 100644 index 0000000000..2d2b8a3066 --- /dev/null +++ b/static/css/TTHoves/TTHoves.css @@ -0,0 +1,39 @@ +@font-face { + font-family: 'TT Hoves'; + src: url('./TTHoves-Bold.woff2') format('woff2'), + url('./TTHoves-Bold.woff') format('woff'); + font-weight: bold; + font-style: normal; +} + +@font-face { + font-family: 'TT Hoves'; + src: url('./TTHoves-Regular.woff2') format('woff2'), + url('./TTHoves-Regular.woff') format('woff'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'TT Hoves'; + src: url('./TTHoves-Light.woff2') format('woff2'), + url('./TTHoves-Light.woff') format('woff'); + font-weight: 300; + font-style: normal; +} + +@font-face { + font-family: 'TT Hoves'; + src: url('./TTHoves-DemiBold.woff2') format('woff2'), + url('./TTHoves-DemiBold.woff') format('woff'); + font-weight: 600; + font-style: normal; +} + +@font-face { + font-family: 'TT Hoves'; + src: url('./TTHoves-Medium.woff2') format('woff2'), + url('./TTHoves-Medium.woff') format('woff'); + font-weight: 500; + font-style: normal; +} \ No newline at end of file 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 28aa0be4cc..e556ced348 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1,341 +1,705 @@ -html, body { +@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 { - width: 100%; min-height: 100%; - background-color: #ffffff; - font-family: "Raleway", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif; - font-size: 11px; + margin: 0; + background: linear-gradient(to bottom, #f6f6f6 360px, #e5e5e5 0), #e5e5e5; + background-repeat: no-repeat; } a { - /*color: #428bca;*/ + color: #00854d; text-decoration: none; } -h1 small { - font-size: 65%; +a:hover { + color: #00854d; + text-decoration: underline; } -h3 { - margin-top: 20px; +select { + border-radius: 0.5rem; + padding-left: 0.5rem; + border: 1px solid #ced4da; + color: var(--bs-body-color); + min-height: 45px; } -.octicon { - color: #777; - display: inline-block; - vertical-align: text-top; - fill: currentColor; - height: 16px; +#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; } -.navbar-form { - padding-bottom: 1px; +#header a { + color: var(--bs-navbar-brand-color); } -.navbar-form .form-control { - background-color: #f5f5f5;; - color: #0f0f0f; - border-radius: 70px; - -webkit-border-radius: 70px; - -moz-border-radius: 70px; - border: 0; - -webkit-box-shadow: 1px 1px 0 0 rgba(255, 255, 255, .41), inset 1px 1px 3px 0 rgba(0, 0, 0, .10); - -moz-box-shadow: 1px 1px 0 0 rgba(255, 255, 255, .41), inset 1px 1px 3px 0 rgba(0, 0, 0, .10); - box-shadow: 1px 1px 0 0 rgba(255, 255, 255, .41), inset 1px 1px 3px 0 rgba(0, 0, 0, .10); +#header a:hover { + color: var(--bs-navbar-brand-hover-color); } -@media (min-width: 768px) { - .container { - max-width: 750px; - } +#header .navbar { + --bs-navbar-padding-y: 0.7rem; } -@media (min-width: 992px) { - body { - font-size: 12px; - } - .container { - max-width: 970px; - } - .octicon { - height: 24px; - } - .navbar-form .form-control { - width: 230px; - } +#header .form-control-lg { + font-size: 1rem; + padding: 0.75rem 1rem; } -@media (min-width: 1200px) { - body { - font-size: 14px; - } - .container { - max-width: 1170px; - } - .octicon { - height: 32px; - } - .navbar-form .form-control { - width: 360px; - } +#header .container { + min-height: 50px; } -#header { - position: absolute; - top: 0; - left: 0; - width: 100%; - margin: 0; - padding-bottom: 0; - padding-top: 0; - background-color: #122036; - border: 0; +#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; } -.bg-trezor { - padding-top: 3px; - padding-bottom: 2px; - z-index: 2; +.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,"); } -.bg-trezor .navbar-brand { - /*color: #4D76B8;*/ +.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; - font-size: 19px; - fill: #FFFFFF; + background-color: white; } -@media (max-width: 768px) { - .navbar { - font-size: 14px; - } - .bg-trezor .navbar-brand { - font-weight: normal; - font-size: 14px; - } +.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: 75px 0; - margin: 0 auto -42px; + padding: 112px 0 75px 0; + margin: 0 auto -56px; } #footer { - background-color: #1B2E4D; - /*color: #fff;*/ - height: 42px; + background-color: black; + color: #757575; + height: 56px; overflow: hidden; } - footer svg { - margin-top: .5em; - width : 20px; - height: 20px; - fill: #4CA1CF; - } -.alert-data { - color: #383d41; - background-color: #f4f4f4; - border-color: #d6d8db; - padding: 15px; +.navbar-form { + width: 60%; } -.line-top { - border-top: 1px solid #EAEAEA; - padding: 10px 0 0; +.navbar-form button { + margin-left: -50px; + position: relative; } -.line-mid { - padding: 15px; +.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"); } -.line-bot { - border-bottom: 2px solid #EAEAEA; - padding: 0 0 15px; +.navbar-form ::placeholder { + color: #e2e2e2; } -.right { - text-align: right +.ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.center { - text-align: center +.data-table { + table-layout: fixed; + overflow-wrap: anywhere; + margin-left: 8px; + margin-top: 2rem; + margin-bottom: 2rem; + width: calc(100% - 16px); } -.txvalues { - display: inline-block; - padding: .7em 2em; - font-size: 13px; - color: #fff; - text-align: center; +.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; +} + +/* SYSCOIN: prevent summary labels from collapsing vertically in fixed-layout info tables. */ +.info-table tr>td:first-child { + font-weight: normal; + color: #757575; white-space: nowrap; - vertical-align: baseline; - border-radius: .25em; - margin-top: 5px; + width: 25%; + overflow-wrap: normal; +} + +.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; } -.txvalues:not(:last-child) { - margin-right: 5px; +.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"); } -.txvalues-default { - background-color: #EBEBEB; - color: #333; +.h-data { + letter-spacing: 0.12em; + font-weight: normal !important; } -.txvalues-success { - background-color: dimgray; +.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; } -.txvalues-primary { - background-color: #000; +.tx-detail:first-child { + margin-top: 1rem; } -.txvalues-danger { - background-color: #CB2C70; +.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: #fbf8f0; + background-color: #fff9e3 !important; } .tx-amt { - float: right!important; + float: right !important; } -.tx-in .tx-own .tx-amt { - color: #CB2C70!important; +.spent { + color: #dc3545 !important; } -.tx-out .tx-own .tx-amt { - color: #7D992E!important; +.unspent { + color: #28a745 !important; } -.tx-addr { - float: left!important; +.outpoint { + color: #757575 !important; } -.ellipsis { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.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"); } -.data-div { - margin: 20px 0 30px 0; +.txvalue { + color: var(--bs-body-color); + font-weight: bold; } -.data-div .col-md-10 { - padding-left: 0; +.txerror { + color: #c51f13; } -.data-table { - table-layout: fixed; - border-radius: .25rem; - background: white; +.txerror a, +.txerror .txvalue { + color: #c51f13; } -.data-table td, .data-table th { - padding: .4rem; +.txerror .copyable::before, +.txerror .copied::before { + /* turn svg stroke to red */ + filter: invert(86%) sepia(43%) saturate(732%) hue-rotate(367deg) brightness(84%); } -.data-table span.ellipsis { - max-width: 100%; +.tx-amt .amt:hover, +.tx-amt.amt:hover, +.amt-out>.amt:hover { + color: var(--bs-body-color); } -.data { - font-weight: bold; +.prim-amt { + display: initial; } -table.data-table table.data-table th { - border-top: 0; - font-weight: normal; +.sec-amt { + display: none; } -.alert .data-table { - margin: 0; +.csec-amt { + display: none; } -::-webkit-input-placeholder { - color: #979797!important; - font-style: italic; - font-size: 14px; - text-indent: 1em; +.base-amt { + display: none; } -::-moz-placeholder { - color: #979797!important; - font-style: italic; - font-size: 14px; - text-indent: 1em; +.cbase-amt { + display: none; } -.h-container ul, .h-container h3 { - margin: 0; +.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)); } -.h-container h5 { - margin-top: 6px; - margin-bottom: 0; +.l-tooltip { + text-align: start; + display: inline-block; } -.page-link { - color: #428bca; +.l-tooltip .prim-amt, +.l-tooltip .sec-amt, +.l-tooltip .csec-amt, +.l-tooltip .base-amt, +.l-tooltip .cbase-amt { + display: initial; + float: right; } -.page-text { - display: block; - padding: .5rem .3rem; - line-height: 1.25; +.l-tooltip .amt-time { + padding-right: 3rem; + float: left; } -.page-link { - color: #4D76B8; +.amt-dec { + font-size: 95%; } -.page-item.active .page-link { - background-color: #428bca; +.unconfirmed { + color: white; + background-color: #c51e13; + padding: 0.7rem 1.2rem; + border-radius: 1.4rem; } -#txSpecific { - margin: 0; +.json { + word-wrap: break-word; + font-size: smaller; + background: #002b31; + border-radius: 8px; } -.string { - color: #4D76B8; +#raw { + padding: 1.5rem 2rem; + color: #ffffff; + letter-spacing: 0.02em; } -.number, .boolean { - color: darkred; +#raw .string { + color: #2bca87; } -.null { - color: red; +#raw .number, +#raw .boolean { + color: #efc941; } -.key { - color: #333; +#raw .null { + color: red; } -.text-success { - color: #7D992E!important; +@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; + } } -.text-warning { - color: #CB2C70!important; +@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 + } } -.navbar-dark .navbar-nav .nav-link { - color: #4CA1CF; +@media (min-width: 769px) { + body { + font-size: 0.9rem; + } + + .btn { + --bs-btn-font-size: 0.9rem; + } } -svg { - width: 35px; - height: 35px; +@media (min-width: 1200px) { + + .h1, + h1 { + font-size: 2.4rem; + } + + body { + font-size: 1rem; + } + + .btn { + --bs-btn-font-size: 1rem; + } } diff --git a/static/css/main.min.4.css b/static/css/main.min.4.css new file mode 100644 index 0000000000..8012cf5364 --- /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;}/* SYSCOIN: prevent summary labels from collapsing vertically in fixed-layout info tables. */.info-table tr>td:first-child {font-weight: normal;color: #757575;white-space: nowrap;width: 25%;overflow-wrap: normal;}.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/favicon.ico b/static/favicon.ico index cf4bfba216..a1b20e0a26 100644 Binary files a/static/favicon.ico and b/static/favicon.ico differ 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..b69754997e --- /dev/null +++ b/static/internal_templates/ws_limit_exceeding_ips.html @@ -0,0 +1,67 @@ +{{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}}
+
+ +

IP addresses blocked for exceeding the websocket message rate limit

+
+
Distinct client keys (IPv4 address or IPv6 /64 prefix) currently blocked from opening new websocket connections: {{len .WsBlockedIPs}}
+
+
+ + +
+
+
+
+ + + + + + + + + + + + + {{range $d := .WsBlockedIPs}} + + + + + + + + + {{end}} + +
KeyBlocked atExpiresRemainingBreachesRejected connections
{{$d.Key}}{{$d.BlockedAt}}{{$d.Until}}{{$d.Remaining}}{{$d.Breaches}}{{$d.Rejected}}
+
+{{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 new file mode 100644 index 0000000000..b1d31408f8 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,218 @@ +function syntaxHighlight(json) { + json = JSON.stringify(json, undefined, 2); + json = json + .replace(/&/g, "&") + .replace(//g, ">"); + if (json.length > 1000000) { + return `${json}`; + } + return json.replace( + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, + (match) => { + let cls = "number"; + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = "key"; + } else { + cls = "string"; + } + } else if (/true|false/.test(match)) { + cls = "boolean"; + } else if (/null/.test(match)) { + cls = "null"; + } + return `${match}`; + } + ); +} + +function getCoinCookie() { + if(hasSecondary) return document.cookie + .split("; ") + .find((row) => row.startsWith("secondary_coin=")) + ?.split("="); +} + +function changeCSSStyle(selector, cssProp, cssVal) { + const mIndex = 1; + const cssRules = document.all ? "rules" : "cssRules"; + for ( + i = 0, len = document.styleSheets[mIndex][cssRules].length; + i < len; + i++ + ) { + if (document.styleSheets[mIndex][cssRules][i].selectorText === selector) { + document.styleSheets[mIndex][cssRules][i].style[cssProp] = cssVal; + return; + } + } +} + +function amountTooltip() { + const prim = this.querySelector(".prim-amt"); + const sec = this.querySelector(".sec-amt"); + const csec = this.querySelector(".csec-amt"); + const base = this.querySelector(".base-amt"); + const cbase = this.querySelector(".cbase-amt"); + let s = `${prim.outerHTML}
`; + if (base) { + let t = base.getAttribute("tm"); + if (!t) { + t = "now"; + } + s += `${t}${base.outerHTML}
`; + } + if (cbase) { + s += `now${cbase.outerHTML}
`; + } + if (sec) { + let t = sec.getAttribute("tm"); + if (!t) { + t = "now"; + } + s += `${t}${sec.outerHTML}
`; + } + if (csec) { + s += `now${csec.outerHTML}
`; + } + return `${s}`; +} + +function addressAliasTooltip() { + const type = this.getAttribute("alias-type"); + const address = this.getAttribute("cc"); + 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) { + if (a[2] === "true") { + changeCSSStyle(".prim-amt", "display", "none"); + changeCSSStyle(".sec-amt", "display", "initial"); + } + document + .querySelectorAll(".amt") + .forEach( + (e) => new bootstrap.Tooltip(e, { title: amountTooltip, html: true }) + ); + } + + document + .querySelectorAll("[alias-type]") + .forEach( + (e) => + new bootstrap.Tooltip(e, { title: addressAliasTooltip, html: true }) + ); + + document + .querySelectorAll("[tt]") + .forEach((e) => new bootstrap.Tooltip(e, { title: e.getAttribute("tt") })); + + document.querySelectorAll("#header .bb-group>.btn-check").forEach((e) => + e.addEventListener("click", (e) => { + const a = getCoinCookie(); + const sc = e.target.id === "secondary-coin"; + if (a?.length === 3 && (a[2] === "true") !== sc) { + document.cookie = `${a[0]}=${a[1]}=${sc}; Path=/`; + changeCSSStyle(".prim-amt", "display", sc ? "none" : "initial"); + changeCSSStyle(".sec-amt", "display", sc ? "initial" : "none"); + } + }) + ); + + document.querySelectorAll(".copyable").forEach((e) => + e.addEventListener("click", (e) => { + if (e.clientX < e.target.getBoundingClientRect().x) { + let t = e.target.getAttribute("cc"); + if (!t) t = e.target.innerText; + const textToCopy = t.trim(); + navigator.clipboard.writeText(textToCopy); + e.target.className = e.target.className.replace("copyable", "copied"); + setTimeout( + () => + (e.target.className = e.target.className.replace( + "copied", + "copyable" + )), + 1000 + ); + e.preventDefault(); + } + }) + ); +}); 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 6ae02357d5..beac64637d 100644 --- a/static/templates/address.html +++ b/static/templates/address.html @@ -1,173 +1,396 @@ -{{define "specific"}}{{$cs := .CoinShortcut}}{{$addr := .Address}}{{$data := .}} -

{{if $addr.Erc20Contract}}Contract {{$addr.Erc20Contract.Name}} ({{$addr.Erc20Contract.Symbol}}){{else}}Address{{end}} {{formatAmount $addr.BalanceSat}} {{$cs}} -

-
- {{$addr.AddrStr}} -
-

Confirmed

-
-
- - - {{- if eq .ChainType 1 -}} - - - - - - - - - - - - - - - - - {{- if $addr.Tokens -}} - - - - - {{- end -}} - - {{- else -}} - - - - - - - - - - - - - - - - - {{- if $addr.Tokens -}} - - - - - {{- end -}} - {{- end -}} - -
Balance{{formatAmount $addr.BalanceSat}} {{$cs}}
Transactions{{$addr.Txs}}
Non-token Transactions{{$addr.NonTokenTxs}}
Nonce{{$addr.Nonce}}
ERC20 Tokens - - - - - - - - {{- range $t := $addr.Tokens -}}{{- if $t -}} - - - - - - {{- end -}}{{- end -}} - -
ContractTokensTransfers
{{if $t.Contract}}{{$t.Name}}{{else}}{{$t.Name}}{{end}}{{formatAmountWithDecimals $t.BalanceSat $t.Decimals}} {{$t.Symbol}}{{$t.Transfers}}
-
Total Received{{formatAmount $addr.TotalReceivedSat}} {{$cs}}
Total Sent{{formatAmount $addr.TotalSentSat}} {{$cs}}
Final Balance{{formatAmount $addr.BalanceSat}} {{$cs}}
No. Transactions{{$addr.Txs}}
Tokens - - - - - - - - - - {{- range $t := $addr.Tokens -}}{{- if $t -}} - - - - - - - - {{- end -}}{{- end -}} - -
AssetTotal ReceivedTotal SentFinal BalanceTransfers
{{if $t.AssetGuid}}{{$t.AssetGuid}}{{else}}{{$t.Name}}{{end}} {{$t.Symbol}}{{formatAmountWithDecimals $t.TotalReceivedSat $t.Decimals}}{{formatAmountWithDecimals $t.TotalSentSat $t.Decimals}}{{formatAmountWithDecimals $t.BalanceSat $t.Decimals}}{{$t.Transfers}}
-
+{{define "specific"}}{{$addr := .Address}}{{$data := .}} +
+
+

{{if $addr.ContractInfo}}Contract {{$addr.ContractInfo.Name}}{{if $addr.ContractInfo.Symbol}} ({{$addr.ContractInfo.Symbol}}){{end}}{{else}}Address {{addressAlias $addr.AddrStr $data}}{{end}}

+
{{$addr.AddrStr}}
+

+
{{formattedAmountSpan $addr.BalanceSat 0 $data.CoinShortcut $data "copyable"}}
+ {{if $addr.SecondaryValue}}
{{summaryValuesSpan 0 $addr.SecondaryValue $data}}
{{end}} +

+ {{if gt $addr.TotalSecondaryValue $addr.SecondaryValue}} +
Including Tokens
+

+
{{summaryValuesSpan $addr.TotalBaseValue 0 $data}}
+
{{summaryValuesSpan 0 $addr.TotalSecondaryValue $data}}
+

+ {{end}}
-
-
+
+
-{{- if $addr.UnconfirmedTxs -}} -

Unconfirmed

-
- - - - - - - - - - - {{- if $addr.Tokens -}} - - - - - {{- end -}} - -
Unconfirmed Balance{{formatAmount $addr.UnconfirmedBalanceSat}} {{$cs}}
No. Transactions{{$addr.UnconfirmedTxs}}
Tokens - - - - - - - - {{- range $t := $addr.Tokens -}}{{- if $t -}}{{- if $t.UnconfirmedBalanceSat -}} - - - - - - {{- end -}}{{- end -}}{{- end -}} - -
AssetUnconfirmed BalanceTransfers
{{if $t.AssetGuid}}{{$t.AssetGuid}}{{else}}{{$t.Name}}{{end}} {{$t.Symbol}}{{formatAmountWithDecimals $t.UnconfirmedBalanceSat $t.Decimals}}{{$t.UnconfirmedTransfers}}
-
+ + + + + + + {{if eq .ChainType 1}} + + + + + + + + + + + + + + + + + + + + + {{template "addressChainExtra" .}} + {{if $addr.ContractInfo}} + {{if $addr.ContractInfo.Standard}} + + + + + {{end}} + {{if $addr.ContractInfo.CreatedInBlock}} + + + + + {{end}} + {{if $addr.ContractInfo.DestructedInBlock}} + + + + + {{end}} + {{end}} + {{else}} + + + + + + + + + + + + + + + + + {{end}} + +
Confirmed
Balance{{amountSpan $addr.BalanceSat $data "copyable"}}
Transactions{{formatInt $addr.Txs}}
Non-contract Transactions{{formatInt $addr.NonTokenTxs}}
Internal Transactions{{formatInt $addr.InternalTxs}}
Nonce{{$addr.Nonce}}
Standard{{$addr.ContractInfo.Standard}}
Created in Block{{formatUint32 $addr.ContractInfo.CreatedInBlock}}
Destructed in Block{{formatUint32 $addr.ContractInfo.DestructedInBlock}}
Total Received{{amountSpan $addr.TotalReceivedSat $data "copyable"}}
Total Sent{{amountSpan $addr.TotalSentSat $data "copyable"}}
Final Balance{{amountSpan $addr.BalanceSat $data "copyable"}}
No. Transactions{{formatInt $addr.Txs}}
+{{if $addr.UnconfirmedTxs}} + + + + + + + + + + + + + + + {{/* SYSCOIN: show unconfirmed SPT balances on normal address pages. */}} + {{if isSyscoinShortcut .CoinShortcut}} + {{if $addr.TokensAsset}} + + + + + {{end}} + {{end}} + +
Unconfirmed
Unconfirmed Balance{{amountSpan $addr.UnconfirmedBalanceSat $data "copyable"}}
No. Transactions{{formatInt $addr.UnconfirmedTxs}}
Syscoin Assets + + + + + + + + {{range $t := $addr.TokensAsset}} + {{if $t.UnconfirmedBalanceSat}} + + + + + + {{end}} + {{end}} + +
AssetUnconfirmed BalanceTransfers#
{{$t.AssetGuid}} {{if $t.Symbol}}{{$t.Symbol}}{{else}}SPT{{end}}{{formattedAmountSpan $t.UnconfirmedBalanceSat $t.Decimals $t.Symbol $data "copyable"}}{{formatInt $t.UnconfirmedTransfers}}
+
+{{end}} +{{/* SYSCOIN: show SPT balances on normal address pages. */}} +{{if isSyscoinShortcut .CoinShortcut}} +{{if $addr.TokensAsset}} +
+
+
+ +
+
+
+ + + + + + + + + + + + {{range $t := $addr.TokensAsset}} + + + + + + + + + + {{end}} + +
Asset GUIDSymbolReceivedSentBalanceUnconfirmedTransfers#
{{$t.AssetGuid}}{{if $t.Symbol}}{{$t.Symbol}}{{else}}SPT{{end}}{{formattedAmountSpan $t.TotalReceivedSat $t.Decimals $t.Symbol $data "copyable"}}{{formattedAmountSpan $t.TotalSentSat $t.Decimals $t.Symbol $data "copyable"}}{{formattedAmountSpan $t.BalanceSat $t.Decimals $t.Symbol $data "copyable"}}{{if $t.UnconfirmedBalanceSat}}{{formattedAmountSpan $t.UnconfirmedBalanceSat $t.Decimals $t.Symbol $data "copyable"}}{{else}}0{{end}}{{formatInt $t.Transfers}}
+
+
+
+
+{{end}} +{{end}} +{{if eq .ChainType 1}} +{{if tokenCount $addr.Tokens .FungibleTokenName}} +
+
+
+ +
+
+
+ + + + + + + + + {{range $t := $addr.Tokens}} + {{if eq $t.Standard $.FungibleTokenName}} + + + + + + + {{end}} + {{end}} + +
ContractQuantityValueTransfers#
{{if $t.Name}}{{$t.Name}}{{else}}{{$t.Contract}}{{end}}{{formattedAmountSpan $t.BalanceSat $t.Decimals $t.Symbol $data "copyable"}}{{summaryValuesSpan $t.BaseValue $t.SecondaryValue $data}}{{formatInt $t.Transfers}}
+
+
+
+
+{{end}} +{{if tokenCount $addr.Tokens .NonFungibleTokenName}} +
+
+
+ +
+
+
+ + + + + + + + {{range $t := $addr.Tokens}} + {{if eq $t.Standard $.NonFungibleTokenName}} + + + + + + {{end}} + {{end}} + +
ContractTokensTransfers#
{{if $t.Name}}{{$t.Name}}{{else}}{{$t.Contract}}{{end}} + {{range $i, $iv := $t.Ids}}{{if $i}}, {{end}}{{formatAmountWithDecimals $iv 0}}{{end}} + {{$t.Transfers}}
+
+
+
-{{- end}}{{if or $addr.Transactions $addr.Filter -}} -
-

Transactions

- -
- +{{end}} +{{if tokenCount $addr.Tokens .MultiTokenName}} +
+
+
+ +
+
+
+ + + + + + + + {{range $t := $addr.Tokens}} + {{if eq $t.Standard $.MultiTokenName}} + + + + + + {{end}} + {{end}} + +
ContractTokensTransfers#
{{if $t.Name}}{{$t.Name}}{{else}}{{$t.Contract}}{{end}} + {{range $i, $iv := $t.MultiTokenValues}}{{if $i}}, {{end}}{{formattedAmountSpan $iv.Value 0 $t.Symbol $data ""}} of ID {{$iv.Id}}{{end}} + {{formatInt $t.Transfers}}
+
+
+
+
+{{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}} +
+

Transactions

+
+ {{/* SYSCOIN: preserve SPT transaction filters when the address has assets. */}} + {{if isSyscoinShortcut .CoinShortcut}}{{if $addr.TokensAsset}}{{end}}{{else}} +
+
+ {{template "paging" $data}}
-
- {{- range $tx := $addr.Transactions}}{{$data := setTxToTemplateData $data $tx}}{{template "txdetail" $data}}{{end -}} +
+ {{range $tx := $addr.Transactions}}{{$data := setTxToTemplateData $data $tx}}{{template "txdetail" $data}}{{end}}
- -{{end}}{{end}} \ No newline at end of file +{{template "paging" $data }} +{{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..dc081d7d1b --- /dev/null +++ b/static/templates/address_chainextra_tron.html @@ -0,0 +1,122 @@ +{{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}} + +{{with $staking := $chainExtra.StakingInfoData}} + +
Staking
+ + +{{if $staking.StakedBalanceValue}} + + Staked Balance + {{formattedAmountSpan $staking.StakedBalanceValue 6 "TRX" $data "copyable"}} + +{{else if $staking.StakedBalance}} + + Staked Balance + {{$staking.StakedBalance}} sun + +{{end}} +{{if $staking.StakedBalanceEnergyValue}} + + Staked for Energy + {{formattedAmountSpan $staking.StakedBalanceEnergyValue 6 "TRX" $data "copyable"}} + +{{else if $staking.StakedBalanceEnergy}} + + Staked for Energy + {{$staking.StakedBalanceEnergy}} sun + +{{end}} +{{if $staking.StakedBalanceBandwidthValue}} + + Staked for Bandwidth + {{formattedAmountSpan $staking.StakedBalanceBandwidthValue 6 "TRX" $data "copyable"}} + +{{else if $staking.StakedBalanceBandwidth}} + + Staked for Bandwidth + {{$staking.StakedBalanceBandwidth}} sun + +{{end}} +{{if $staking.UnstakingBatchesData}} + + Unstaking + + {{range $i, $batch := $staking.UnstakingBatchesData}} + {{if $i}}
{{end}} + {{if $batch.AmountValue}}{{formattedAmountSpan $batch.AmountValue 6 "TRX" $data "copyable"}}{{else if $batch.Amount}}{{$batch.Amount}} sun{{end}} + {{if $batch.ExpireTime}} available {{unixTimeSpan $batch.ExpireTime}}{{end}} + {{end}} + + +{{end}} +{{if or $staking.AvailableVotingPower $staking.TotalVotingPower}} + + Voting Power + {{$staking.AvailableVotingPower}} / {{$staking.TotalVotingPower}} + +{{end}} +{{if $staking.Votes}} + + Votes + + {{range $i, $vote := $staking.Votes}} + {{if $i}}
{{end}} + {{if $vote.VoteCount}}{{$vote.VoteCount}}{{end}} + {{if $vote.Address}}{{if $vote.VoteCount}} for {{end}}{{addressAliasSpan $vote.Address $data}}{{end}} + {{end}} + + +{{end}} +{{if $staking.UnclaimedRewardValue}} + + Unclaimed Reward + {{formattedAmountSpan $staking.UnclaimedRewardValue 6 "TRX" $data "copyable"}} + +{{else if $staking.UnclaimedReward}} + + Unclaimed Reward + {{$staking.UnclaimedReward}} sun + +{{end}} +{{if $staking.DelegatedBalanceEnergyValue}} + + Delegated for Energy + {{formattedAmountSpan $staking.DelegatedBalanceEnergyValue 6 "TRX" $data "copyable"}} + +{{else if $staking.DelegatedBalanceEnergy}} + + Delegated for Energy + {{$staking.DelegatedBalanceEnergy}} sun + +{{end}} +{{if $staking.DelegatedBalanceBandwidthValue}} + + Delegated for Bandwidth + {{formattedAmountSpan $staking.DelegatedBalanceBandwidthValue 6 "TRX" $data "copyable"}} + +{{else if $staking.DelegatedBalanceBandwidth}} + + Delegated for Bandwidth + {{$staking.DelegatedBalanceBandwidth}} sun + +{{end}} +{{end}} +{{end}} +{{end}} diff --git a/static/templates/asset.html b/static/templates/asset.html index 7835052e6b..7194632e82 100644 --- a/static/templates/asset.html +++ b/static/templates/asset.html @@ -1,76 +1,85 @@ -{{define "specific"}}{{$cs := .CoinShortcut}}{{$asset := .Asset}}{{$data := .}} -

Asset {{$asset.AssetDetails.Symbol}}

-
- {{$asset.AssetDetails.AssetGuid}} +{{define "specific"}}{{$asset := .Asset}}{{$data := .}} +
+

Asset {{$asset.AssetDetails.Symbol}}

-

Details

-
-
- - - {{- if isNFT $asset.AssetDetails.AssetGuid -}} - - - - - - - {{- end -}} - - - - - - - - - {{if $asset.AssetDetails.MetaData}} - - - - - {{- end -}} - -
NFT ID{{formatNFTID $asset.AssetDetails.AssetGuid}}Base Asset GUID{{formatBaseAssetID $asset.AssetDetails.AssetGuid}}
Transactions{{$asset.Txs}}
Contract{{$asset.AssetDetails.Contract}}
Metadata{{$asset.AssetDetails.MetaData}}
-
-
-
- - -
+
+
{{$asset.AssetDetails.AssetGuid}}
-{{- if $asset.UnconfirmedTxs -}} -

Unconfirmed

-
- - - - - - - - - - - -
Unconfirmed Balance{{formatAmountWithDecimals $asset.UnconfirmedBalanceSat $asset.AssetDetails.Decimals}} {{$asset.AssetDetails.Symbol}}
No. Transactions{{$asset.UnconfirmedTxs}}
-
-{{- end}}{{if or $asset.Transactions $asset.Filter -}} -
-

Transactions

- -
- + + + {{if isNFT $asset.AssetDetails.AssetGuid}} + + + + + + + + + {{end}} + + + + + {{if $asset.AssetDetails.Contract}} + + + {{$contractURL := formatContractExplorerURL $asset.AssetDetails.Contract}} + + + {{end}} + {{if not (isSyscoinNativeAsset $asset.AssetDetails.AssetGuid)}} + + + + + + + + + {{end}} + {{if $asset.AssetDetails.MetaData}} + + + + + {{end}} + +
NFT ID{{formatNFTID $asset.AssetDetails.AssetGuid}}
Base Asset GUID{{formatBaseAssetID $asset.AssetDetails.AssetGuid}}
Transactions{{formatInt $asset.Txs}}
Contract{{if $contractURL}}{{$asset.AssetDetails.Contract}}{{else}}{{$asset.AssetDetails.Contract}}{{end}}
Total Supply{{formattedAmountSpan $asset.AssetDetails.TotalSupply $asset.AssetDetails.Decimals $asset.AssetDetails.Symbol $data "copyable"}}
Max Supply{{formattedAmountSpan $asset.AssetDetails.MaxSupply $asset.AssetDetails.Decimals $asset.AssetDetails.Symbol $data "copyable"}}
Metadata{{$asset.AssetDetails.MetaData}}
+{{if $asset.UnconfirmedTxs}} + + + + + + + + + + + + + + + +
Unconfirmed
Unconfirmed Balance{{formattedAmountSpan $asset.UnconfirmedBalanceSat $asset.AssetDetails.Decimals $asset.AssetDetails.Symbol $data "copyable"}}
No. Transactions{{formatInt $asset.UnconfirmedTxs}}
+{{end}} +{{if or $asset.Transactions $asset.Filter}} +
+

Transactions

+
+ +
+
+ {{template "paging" $data}}
-
- {{- range $tx := $asset.Transactions}}{{$data := setTxToTemplateData $data $tx}}{{template "txdetail" $data}}{{end -}} +
+ {{range $tx := $asset.Transactions}}{{$data := setTxToTemplateData $data $tx}}{{template "txdetail" $data}}{{end}}
- -{{end}}{{end}} \ No newline at end of file +{{template "paging" $data}} +{{end}}{{end}} diff --git a/static/templates/assets.html b/static/templates/assets.html index b817dca195..7448484670 100644 --- a/static/templates/assets.html +++ b/static/templates/assets.html @@ -1,38 +1,35 @@ -{{define "specific"}} -{{$cs := .CoinShortcut}}{{$assets := .Assets}}{{$data := .}} -

Assets Filtered by {{$assets.Filter}}

-
-
{{$assets.NumAssets}} Assets found
- +{{define "specific"}}{{$assets := .Assets}}{{$data := .}} +
+

Assets Filtered by {{$assets.Filter}}

-
- - - - - - - - - - - {{- range $assetDetails := $assets.AssetDetails -}}{{- if $assetDetails -}} - - - - - - - {{- end -}}{{- end -}} - -
AssetTransactionsContractMetadata
- {{$assetDetails.AssetGuid}}  - {{$assetDetails.Symbol}} - {{$assetDetails.Txs}} - {{$assetDetails.Contract}} - - {{- if $assetDetails.MetaData}}{{$assetDetails.MetaData}}{{end -}} -
+
+
{{formatInt $assets.NumAssets}} Assets found
+
{{template "paging" $data}}
- + + + + + + + + + {{range $assetDetails := $assets.AssetDetails}} + {{if $assetDetails}} + + + + + + + {{end}} + {{end}} + +
AssetTransactionsContractMetadata
+ {{$assetDetails.AssetGuid}}  + {{$assetDetails.Symbol}} + {{formatInt $assetDetails.Txs}} + {{if $assetDetails.Contract}}{{$contractURL := formatContractExplorerURL $assetDetails.Contract}}{{if $contractURL}}{{$assetDetails.Contract}}{{else}}{{$assetDetails.Contract}}{{end}}{{end}} + {{if $assetDetails.MetaData}}{{$assetDetails.MetaData}}{{end}}
+{{template "paging" $data}} {{end}} diff --git a/static/templates/base.html b/static/templates/base.html index 0bbe0065cc..5df5648214 100644 --- a/static/templates/base.html +++ b/static/templates/base.html @@ -4,89 +4,116 @@ - - - - + + + + + - - {{.CoinLabel}} Explorer + + Trezor {{.CoinLabel}} Explorer
+
{{- template "specific" . -}}
-